diff --git a/.github/actions/build/action.yaml b/.github/actions/build/action.yaml index 02e58c8b..710c8bd1 100644 --- a/.github/actions/build/action.yaml +++ b/.github/actions/build/action.yaml @@ -14,5 +14,5 @@ runs: run: | mkdir build $buildDir = Join-Path . build - $commandDir = Join-Path . cmd fs - go build -o ${buildDir} ${commandDir} + $buildCommand = Join-Path . cmd build + go run $buildCommand -o $buildDir diff --git a/.github/actions/latest/action.yaml b/.github/actions/latest/action.yaml new file mode 100644 index 00000000..29b45a77 --- /dev/null +++ b/.github/actions/latest/action.yaml @@ -0,0 +1,18 @@ +name: "Latest" +description: 'Update the GitHub release tagged "latest"' +inputs: + github-token: + description: "Token used to publish releases" + required: true +runs: + using: "composite" + steps: + - shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + run: | + git tag --force latest master + git push --force origin latest + pushd artifact + gh release upload --clobber latest * + popd diff --git a/.github/actions/prerelease/action.yaml b/.github/actions/prerelease/action.yaml deleted file mode 100644 index 923d2bb3..00000000 --- a/.github/actions/prerelease/action.yaml +++ /dev/null @@ -1,20 +0,0 @@ -name: "Prerelease" -description: "Publish a GitHub (pre-)release" -inputs: - github-token: - description: "Token used to publish releases" - required: true -runs: - using: "composite" - steps: - - shell: bash - env: - GH_TOKEN: ${{ inputs.github-token }} - # TODO: --notes-file something.md; install instructions + mhash of release dir - # TODO: some kind of coordination between artifact / directory names. inputs<=>outputs? - # TODO: use `gh release list` to check if our tag exist first, delete if so. Right now we assume it exists. - run: | - pushd artifact - gh release delete --yes prerelease - gh release create --prerelease --generate-notes --title "Development build" --notes 'Default args for mounting IPFS should work now if `$IPFS_PATH` is initialized and the IPFS daemon is running. Not well tested yet.' prerelease * - popd diff --git a/.github/actions/test/action.yaml b/.github/actions/test/action.yaml index ad5aae74..a1680185 100644 --- a/.github/actions/test/action.yaml +++ b/.github/actions/test/action.yaml @@ -12,6 +12,26 @@ runs: env: CPATH: ${{env.CPATH}} run: | - $commandPath = $(Join-Path . ...) + $commandPath = Join-Path . ... + $tagValues = & { + $tags = 'nofuse', 'noipfs' + $combinations = @() + $allCombinations = [Math]::Pow(2, $tags.Length) + for ($i = 0; $i -lt $allCombinations; $i++) { + $components = @() + for ($j = 0; $j -lt $tags.Length; $j++) { + if (($i -band ([Math]::Pow(2, $j))) -ne 0) { + $components += $tags[$j] + } + } + if ($components.Count -gt 0) { + $combinations += $components -join ',' + } + } + return $combinations + } + $tagValues | ForEach-Object { + "go vet -tags=$_ ${commandPath}" + } go test -cover ${commandPath} go test -race ${commandPath} diff --git a/.github/workflows/action.yaml b/.github/workflows/action.yaml index 26d2ee4e..14997265 100644 --- a/.github/workflows/action.yaml +++ b/.github/workflows/action.yaml @@ -3,9 +3,6 @@ on: push: branches: - master - tags: - - 'prerelease' - - 'release' pull_request: jobs: Test: @@ -18,7 +15,7 @@ jobs: - uses: ./.github/actions/setup - uses: ./.github/actions/test Build: - if: github.ref == 'refs/tags/prerelease' || github.ref == 'refs/tags/release' + if: github.ref == 'refs/heads/master' strategy: matrix: os: [windows-latest, macos-latest, ubuntu-latest] @@ -28,13 +25,13 @@ jobs: - uses: ./.github/actions/setup - uses: ./.github/actions/build - uses: ./.github/actions/archive - Prerelease: - if: github.ref == 'refs/tags/prerelease' # TODO: invert this? If prerelease; setup + test + build + release + Release: + if: github.ref == 'refs/heads/master' needs: [Test, Build] runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/download-artifact@v3 - - uses: ./.github/actions/prerelease + - uses: ./.github/actions/latest with: github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/cmd/build/buildmode_string.go b/cmd/build/buildmode_string.go new file mode 100644 index 00000000..2be8393d --- /dev/null +++ b/cmd/build/buildmode_string.go @@ -0,0 +1,25 @@ +// Code generated by "stringer -type=buildMode"; DO NOT EDIT. + +package main + +import "strconv" + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[regular-0] + _ = x[release-1] + _ = x[debug-2] +} + +const _buildMode_name = "regularreleasedebug" + +var _buildMode_index = [...]uint8{0, 7, 14, 19} + +func (i buildMode) String() string { + if i < 0 || i >= buildMode(len(_buildMode_index)-1) { + return "buildMode(" + strconv.FormatInt(int64(i), 10) + ")" + } + return _buildMode_name[_buildMode_index[i]:_buildMode_index[i+1]] +} diff --git a/cmd/build/env_nocgo.go b/cmd/build/env_nocgo.go new file mode 100644 index 00000000..856e24b0 --- /dev/null +++ b/cmd/build/env_nocgo.go @@ -0,0 +1,5 @@ +//go:build !cgo + +package main + +func setupEnvironment(environment []string) ([]string, error) { return environment, nil } diff --git a/cmd/build/env_other.go b/cmd/build/env_other.go new file mode 100644 index 00000000..8aab54d5 --- /dev/null +++ b/cmd/build/env_other.go @@ -0,0 +1,5 @@ +//go:build cgo && !windows + +package main + +func setupEnvironment(environment []string) ([]string, error) { return environment, nil } diff --git a/cmd/build/env_windows.go b/cmd/build/env_windows.go new file mode 100644 index 00000000..62848bf7 --- /dev/null +++ b/cmd/build/env_windows.go @@ -0,0 +1,98 @@ +//go:build cgo + +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +func setupEnvironment(environment []string) ([]string, error) { + const librarySearchPathKey = "CPATH" + searchPaths, searchPathsIndex, searchPathsSet := lookupEnv(environment, librarySearchPathKey) + if searchPathsSet { + if hasFuseHeader(searchPaths) { + return environment, nil + } + } + fuseLibPath, err := getDefaultFUSELibPath() + if err != nil { + return nil, err + } + if searchPathsSet { + newSearchPaths := appendSearchPaths(searchPaths, fuseLibPath) + setEnv(environment, searchPathsIndex, librarySearchPathKey, newSearchPaths) + } else { + environment = append(environment, newEnvPair(librarySearchPathKey, fuseLibPath)) + } + return environment, nil +} + +func lookupEnv(environment []string, key string) (string, int, bool) { + for i, pair := range environment { + if strings.HasPrefix(pair, key) { + const separator = "=" + valueIndex := len(key) + len(separator) + return pair[valueIndex:], i, true + } + } + return "", -1, false +} + +func hasFuseHeader(pathList string) bool { + paths := strings.Split(pathList, string(os.PathListSeparator)) + const headerName = "fuse.h" + for _, path := range paths { + headerPath := filepath.Join(path, headerName) + if _, err := os.Stat(headerPath); err == nil { + return true + } + } + return false +} + +func getDefaultFUSELibPath() (string, error) { + const findErrFmt = `could not find WinFSP's FUSE library "%s"` + fuseLibPath := getFUSELibPath() + if _, err := os.Stat(fuseLibPath); err != nil { + return "", fmt.Errorf(findErrFmt+": %w", fuseLibPath, err) + } + if !hasFuseHeader(fuseLibPath) { + return "", fmt.Errorf(findErrFmt, fuseLibPath) + } + return fuseLibPath, nil +} + +func getFUSELibPath() string { + return filepath.Join( + getProgramsPath(), + "WinFsp", "inc", "fuse", + ) +} + +func getProgramsPath() string { + const ( + x64SearchPath = "ProgramFiles(x86)" + x86SearchPath = "ProgramFiles" + ) + progPath, ok := os.LookupEnv(x64SearchPath) + if ok { + return progPath + } + return os.Getenv(x86SearchPath) +} + +func appendSearchPaths(searchPaths, path string) string { + return fmt.Sprintf("%s%c%s", searchPaths, os.PathListSeparator, path) +} + +func setEnv(environment []string, index int, key, value string) { + environment[index] = newEnvPair(key, value) +} + +func newEnvPair(key, value string) string { + const separator = "=" + return key + separator + value +} diff --git a/cmd/build/main.go b/cmd/build/main.go index ca992d7d..0f8d3040 100644 --- a/cmd/build/main.go +++ b/cmd/build/main.go @@ -1,47 +1,143 @@ +// Command build attempts to build the fs command. package main import ( + "flag" "fmt" "log" "os" "os/exec" "path/filepath" + "strings" + + "github.com/djdv/go-filesystem-utils/internal/generic" ) -type envDeferFunc func() error +//go:generate stringer -type=buildMode +type buildMode int + +const ( + regular buildMode = iota + release + debug +) func main() { log.SetFlags(log.Lshortfile) + buildMode, tags, output := parseFlags() cwd, err := os.Getwd() if err != nil { - log.Fatal("Could not get working directory:", err) + log.Fatal("could not get working directory:", err) } + const ( + commandRoot = "cmd" + targetCommand = "fs" + ) var ( - pkgGoPath = filepath.Join("cmd", "fs") + pkgGoPath = filepath.Join(commandRoot, targetCommand) pkgFSPath = filepath.Join(cwd, pkgGoPath) ) if _, err := os.Stat(pkgFSPath); err != nil { - log.Fatal("Could not access pkg directory:", err) - } - restoreEnv := setupEnv() - defer func() { - if err := restoreEnv(); err != nil { - log.Println(err) - } - }() - + log.Fatal("could not access pkg directory:", err) + } const ( - goBin = "go" - goBuild = "build" - linkerFlags = "-ldflags=-s -w" + goBin = "go" + goBuild = "build" + maxArgs = 5 ) - goArgs := []string{goBuild, linkerFlags, pkgFSPath} - output, err := exec.Command(goBin, goArgs...).CombinedOutput() + goArgs := make([]string, 1, maxArgs) + goArgs[0] = goBuild + switch buildMode { + case debug: + const compilerDebugFlags = `-gcflags=all=-N -l` + goArgs = append(goArgs, compilerDebugFlags) + case release: + const ( + buildTrimFlag = `-trimpath` + linkerReleaseFlags = `-ldflags=-s -w` + ) + goArgs = append(goArgs, buildTrimFlag, linkerReleaseFlags) + } + if tags != "" { + goArgs = append(goArgs, "-tags="+tags) + } + if output != "" { + goArgs = append(goArgs, "-o="+output) + } + goArgs = append(goArgs, pkgFSPath) + cmd := exec.Command(goBin, goArgs...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + buildEnvironment, err := setupEnvironment(cmd.Environ()) if err != nil { - log.Printf("failed to run build command: %s\n%s", - err, output, + log.Fatal("could not setup process environment:", err) + } + cmd.Env = buildEnvironment + if err := cmd.Run(); err != nil { + log.Fatalf("failed to run build command: %s", err) + } +} + +func parseFlags() (mode buildMode, tags string, output string) { + const ( + regularUsage = "standard go build with no compiler or linker flags when building" + releaseUsage = "remove extra debugging data when building" + debugUsage = "disable optimizations when building" + modeName = "mode" + ) + var ( + cmdName = commandName() + flagSet = flag.NewFlagSet(cmdName, flag.ExitOnError) + modeUsage = fmt.Sprintf( + "%s\t- %s"+ + "\n%s\t- %s"+ + "\n%s\t- %s"+ + "\n\b", + regular.String(), regularUsage, + release.String(), releaseUsage, + debug.String(), debugUsage, + ) + ) + mode = release + flagSet.Func(modeName, modeUsage, func(arg string) (err error) { + mode, err = generic.ParseEnum(regular, debug, arg) + return + }) + flagSet.Lookup(modeName).DefValue = mode.String() + const ( + tagName = "tags" + tagsUsage = "a comma-separated list of build tags" + + "\nsupported in addition to Go's standard tags:" + + "\nnofuse - build without FUSE host support" + + "\nnoipfs - build without IPFS guest support" + ) + flagSet.StringVar(&tags, tagName, "", tagsUsage) + const ( + outputName = "o" + outputUsage = "write the resulting executable" + + " to the named output file or directory" + ) + flagSet.StringVar(&output, outputName, "", outputUsage) + if err := flagSet.Parse(os.Args[1:]); err != nil { + log.Fatal(err) + } + if args := flagSet.Args(); len(args) != 0 { + var output strings.Builder + flagSet.SetOutput(&output) + flagSet.Usage() + log.Fatalf("unexpected arguments: %s\n%s", + strings.Join(args, ", "), + output.String(), ) - os.Exit(1) } - fmt.Fprint(os.Stdout, string(output)) + return +} + +func commandName() string { + execName := filepath.Base(os.Args[0]) + return strings.TrimSuffix( + execName, + filepath.Ext(execName), + ) } diff --git a/cmd/build/main_cgo.go b/cmd/build/main_cgo.go deleted file mode 100644 index 76a118aa..00000000 --- a/cmd/build/main_cgo.go +++ /dev/null @@ -1,68 +0,0 @@ -//go:build cgo - -package main - -import ( - "fmt" - "os" - "path/filepath" - "runtime" - "strings" -) - -func setupEnv() envDeferFunc { - if runtime.GOOS == "windows" { - return setupCEnvWin() - } - return func() error { return nil } -} - -func setupCEnvWin() envDeferFunc { - const ( - x64SearchPath = "ProgramFiles(x86)" - x86SearchPath = "ProgramFiles" - ) - progPath, ok := os.LookupEnv(x64SearchPath) - if !ok { - progPath = os.Getenv(x86SearchPath) - } - fuseLibPath := filepath.Join(progPath, "WinFsp", "inc", "fuse") - if _, err := os.Stat(fuseLibPath); err == nil { - return setupCpathWinFSP(fuseLibPath) - } - return func() error { return nil } -} - -func setupCpathWinFSP(fuseInc string) envDeferFunc { - const compilerLibPathsKey = "CPATH" - cpath, ok := os.LookupEnv(compilerLibPathsKey) - if ok { - libPaths := strings.Split(cpath, string(os.PathListSeparator)) - for _, path := range libPaths { - if path == fuseInc { - return func() error { return nil } - } - } - appendedCpath := fmt.Sprint(cpath, os.PathListSeparator, fuseInc) - if err := os.Setenv(compilerLibPathsKey, appendedCpath); err != nil { - panic(err) - } - return func() error { return os.Setenv(compilerLibPathsKey, cpath) } - } - if err := os.Setenv(compilerLibPathsKey, fuseInc); err != nil { - panic(err) - } - return func() error { return os.Unsetenv(compilerLibPathsKey) } -} - -/* TODO: [lint] we still don't need this yet. -func haveCompiler() bool { - // 2021.08.10 - Currently only GCC is supported on Windows - cCompilers := []string{"gcc"} // Future: "clang", "msvc" - for _, compiler := range cCompilers { - if _, err := exec.LookPath(compiler); err == nil { - return true - } - } -} -*/ diff --git a/cmd/build/main_nocgo.go b/cmd/build/main_nocgo.go deleted file mode 100644 index 6157a4aa..00000000 --- a/cmd/build/main_nocgo.go +++ /dev/null @@ -1,5 +0,0 @@ -//go:build !cgo - -package main - -func setupEnv() envDeferFunc { return func() error { return nil } } diff --git a/cmd/fs/main.go b/cmd/fs/main.go index 39972540..d91135c5 100644 --- a/cmd/fs/main.go +++ b/cmd/fs/main.go @@ -19,32 +19,21 @@ const ( misuse ) -type settings struct { - command.HelpArg -} - -// BindFlags defines settings flags in the [flag.FlagSet]. -func (set *settings) BindFlags(fs *flag.FlagSet) { - set.HelpArg.BindFlags(fs) -} - func main() { const ( synopsis = "File system service utility." - usage = "Currently doesn't do much." ) var ( - cmdName = commandName() - cmdArgs = os.Args[1:] + name = commandName() + arguments = os.Args[1:] subcommands = makeSubcommands() - cmd = command.MakeCommand[*settings]( - cmdName, synopsis, usage, - execute, - command.WithSubcommands(subcommands...), - ) - ctx = context.Background() + ctx = context.Background() + err = command.SubcommandGroup( + name, synopsis, + subcommands, + ).Execute(ctx, arguments...) ) - if err := cmd.Execute(ctx, cmdArgs...); err != nil { + if err != nil { exitWithErr(err) } } @@ -59,12 +48,6 @@ func commandName() string { ) } -// execute is the root [command.CommandFunc] -// and expects to be called with subcommand args. -func execute(context.Context, *settings, ...string) error { - return command.ErrUsage -} - // makeSubcommands returns a set of subcommands. func makeSubcommands() []command.Command { return []command.Command{ @@ -76,30 +59,41 @@ func makeSubcommands() []command.Command { } func exitWithErr(err error) { - const ( - success = iota - failure - misuse - ) + if errors.Is(err, flag.ErrHelp) { + // We must exit with the correct code, + // but don't need to print this error itself. + // The command library will have already printed + // the usage text (as requested). + os.Exit(misuse) + } var ( code int - printErr = func() { - errStr := err.Error() - if !strings.HasSuffix(errStr, "\n") { - errStr += "\n" - } - os.Stderr.WriteString(errStr) - } + usageErr command.UsageError ) - if errors.Is(err, command.ErrUsage) { + if errors.As(err, &usageErr) { + // Inappropriate input. code = misuse - // Only print these errors if they've been wrapped. - if errors.Unwrap(err) != nil { - printErr() - } } else { + // Operation failure. code = failure - printErr() } + errStr := err.Error() + if !strings.HasSuffix(errStr, "\n") { + errStr += "\n" + } + os.Stderr.WriteString(errStr) os.Exit(code) } + +func isWrapped(err error) bool { + if errors.Unwrap(err) != nil { + return true + } + joinErrs, ok := err.(interface { + Unwrap() []error + }) + if ok { + return len(joinErrs.Unwrap()) > 1 + } + return false +} diff --git a/doc/assets/filesystem.svg b/doc/assets/filesystem.svg new file mode 100644 index 00000000..6d8c2160 --- /dev/null +++ b/doc/assets/filesystem.svg @@ -0,0 +1,875 @@ +file systemcontrol/listeners/mounts/

Shutdown file

+

name: "shutdown"
+write: shutdown disposition value
+(string or decimal; e.g. "patient" or 1)

+
/...$maddr-components.../$host-API-name/

Socket file

+

name: "listener"
+read: multiaddr string

+
connections/ + + + + + + + + + + + +$guest-API-name/ + + + + + + + + + + + +

Connection file

+

name: ${decimal number}
+read: connection metadata as JSON

+

Mount point file

+

name: ${NanoID}.json
+read: mount point metadata as JSON

+
+ + +
diff --git a/doc/assets/goda-short.svg b/doc/assets/goda-short.svg new file mode 100644 index 00000000..fb993824 --- /dev/null +++ b/doc/assets/goda-short.svg @@ -0,0 +1,250 @@ + + + + + + +G + + + +github.com/djdv/go-filesystem-utils/cmd/fs + + +github.com/djdv/go-filesystem-utils/cmd/fs + + + + + +github.com/djdv/go-filesystem-utils/internal/command + + +github.com/djdv/go-filesystem-utils/internal/command + + + + + +github.com/djdv/go-filesystem-utils/cmd/fs:e->github.com/djdv/go-filesystem-utils/internal/command + + + + + +github.com/djdv/go-filesystem-utils/internal/commands + + +github.com/djdv/go-filesystem-utils/internal/commands + + + + + +github.com/djdv/go-filesystem-utils/cmd/fs:e->github.com/djdv/go-filesystem-utils/internal/commands + + + + + +github.com/djdv/go-filesystem-utils/internal/generic + + +github.com/djdv/go-filesystem-utils/internal/generic + + + + + +github.com/djdv/go-filesystem-utils/internal/command:e->github.com/djdv/go-filesystem-utils/internal/generic + + + + + +github.com/djdv/go-filesystem-utils/internal/commands:e->github.com/djdv/go-filesystem-utils/internal/command + + + + + +github.com/djdv/go-filesystem-utils/internal/filesystem + + +github.com/djdv/go-filesystem-utils/internal/filesystem + + + + + +github.com/djdv/go-filesystem-utils/internal/commands:e->github.com/djdv/go-filesystem-utils/internal/filesystem + + + + + +github.com/djdv/go-filesystem-utils/internal/filesystem/9p + + +github.com/djdv/go-filesystem-utils/internal/filesystem/9p + + + + + +github.com/djdv/go-filesystem-utils/internal/commands:e->github.com/djdv/go-filesystem-utils/internal/filesystem/9p + + + + + +github.com/djdv/go-filesystem-utils/internal/filesystem/cgofuse + + +github.com/djdv/go-filesystem-utils/internal/filesystem/cgofuse + + + + + +github.com/djdv/go-filesystem-utils/internal/commands:e->github.com/djdv/go-filesystem-utils/internal/filesystem/cgofuse + + + + + +github.com/djdv/go-filesystem-utils/internal/filesystem/ipfs + + +github.com/djdv/go-filesystem-utils/internal/filesystem/ipfs + + + + + +github.com/djdv/go-filesystem-utils/internal/commands:e->github.com/djdv/go-filesystem-utils/internal/filesystem/ipfs + + + + + +github.com/djdv/go-filesystem-utils/internal/commands:e->github.com/djdv/go-filesystem-utils/internal/generic + + + + + +github.com/djdv/go-filesystem-utils/internal/net/9p + + +github.com/djdv/go-filesystem-utils/internal/net/9p + + + + + +github.com/djdv/go-filesystem-utils/internal/commands:e->github.com/djdv/go-filesystem-utils/internal/net/9p + + + + + +github.com/djdv/go-filesystem-utils/internal/filesystem/9p:e->github.com/djdv/go-filesystem-utils/internal/filesystem + + + + + +github.com/djdv/go-filesystem-utils/internal/filesystem/9p:e->github.com/djdv/go-filesystem-utils/internal/generic + + + + + +github.com/djdv/go-filesystem-utils/internal/filesystem/9p:e->github.com/djdv/go-filesystem-utils/internal/net/9p + + + + + +github.com/djdv/go-filesystem-utils/internal/filesystem/cgofuse:e->github.com/djdv/go-filesystem-utils/internal/filesystem + + + + + +github.com/djdv/go-filesystem-utils/internal/filesystem/cgofuse:e->github.com/djdv/go-filesystem-utils/internal/filesystem/9p + + + + + +github.com/djdv/go-filesystem-utils/internal/filesystem/cgofuse/lock + + +github.com/djdv/go-filesystem-utils/internal/filesystem/cgofuse/lock + + + + + +github.com/djdv/go-filesystem-utils/internal/filesystem/cgofuse:e->github.com/djdv/go-filesystem-utils/internal/filesystem/cgofuse/lock + + + + + +github.com/djdv/go-filesystem-utils/internal/filesystem/errors + + +github.com/djdv/go-filesystem-utils/internal/filesystem/errors + + + + + +github.com/djdv/go-filesystem-utils/internal/filesystem/cgofuse:e->github.com/djdv/go-filesystem-utils/internal/filesystem/errors + + + + + +github.com/djdv/go-filesystem-utils/internal/filesystem/cgofuse:e->github.com/djdv/go-filesystem-utils/internal/generic + + + + + +github.com/djdv/go-filesystem-utils/internal/filesystem/ipfs:e->github.com/djdv/go-filesystem-utils/internal/filesystem + + + + + +github.com/djdv/go-filesystem-utils/internal/filesystem/ipfs:e->github.com/djdv/go-filesystem-utils/internal/filesystem/9p + + + + + +github.com/djdv/go-filesystem-utils/internal/filesystem/ipfs:e->github.com/djdv/go-filesystem-utils/internal/filesystem/errors + + + + + +github.com/djdv/go-filesystem-utils/internal/filesystem/ipfs:e->github.com/djdv/go-filesystem-utils/internal/generic + + + + + +github.com/djdv/go-filesystem-utils/internal/net/9p:e->github.com/djdv/go-filesystem-utils/internal/generic + + + + + diff --git a/doc/assets/interactions.svg b/doc/assets/interactions.svg new file mode 100644 index 00000000..ed1b2848 --- /dev/null +++ b/doc/assets/interactions.svg @@ -0,0 +1,113 @@ +Command Line1st-party client process(fs mount, etc.)3rd-party client process(shell extension dll, etc.)File system service(fs daemon)Host API(FUSE, 9P, et al.)Guest API(IPFS, PinFS, et al.)Operating system Text parser 9P9PGo APIGo APIAPI|ABI + + + + + + + + diff --git a/doc/development.md b/doc/development.md new file mode 100644 index 00000000..b64680a5 --- /dev/null +++ b/doc/development.md @@ -0,0 +1,147 @@ +# Architecture + +![goda-cluster-short](assets/goda-short.svg) + +## Package documentation + +The Go project hosts a documentation server that can be accessed here: +https://pkg.go.dev/github.com/djdv/go-filesystem-utils/internal +The public `pkgsite` service hides a lot of information by default, so developers are encouraged +to run their own local [`godoc`](https://pkg.go.dev/golang.org/x/tools/cmd/godoc) server (or any +equivalent documentation server such as `golds`, etc.). +`godoc` allows an `m=all` query string, which will render internal packages and unexported +identifiers. +E.g. `godoc -http:6060` then navigate to "http://127.0.0.1:6060/pkg/github.com/djdv/go-filesystem-utils/?m=all". + +This project separates platform differences at compile time via build constraints, so other +useful query strings are `GOOS` and `GOARCH` which work with both `pkgsite` and `godoc`. +By default, the documentation servers will render documentation for the `$GOOS` and `$GOARCH` of +the requester, but these can be specified explicitly to request documentation sets for other +combinations. +E.g. "https://pkg.go.dev/os?GOOS=windows", "https://pkg.go.dev/os?GOOS=darwin&GOARCH=amd64", etc. + +As APIs stabilize, packages may be moved out of `/internal`, potentially into separate repos. + +## `fs` command + +### Command line interface + +The `fs` command serves multiple roles, acting as both a client and server process. +The package `command` facilitates multiplexing and dispatching between our subcommands. + +### Command implementation + +(Sub)commands are defined as Go functions that have a signature compatible with +one of the `command.Make*Command` constructors. +E.g. `func execute(ctx context.Context, arbitrary ExecuteType...)` +which is compatible with the `command.MakeVariadicCommand` constructor. + +The `ExecuteType` constraint may be any Go type which satisfies its interface. +Specifically, this includes a `BindFlags(*flag.FlagSet)` method which registers the type with +Go's standard `flag.FlagSet` type. + +Package `command` will handle parsing the command line and resolving subcommands, before passing +the expected parameters to the execute function. + +For more details, you can read the examples in the `command` package, or read the +implementations used by the `fs` commands. + +### `fs` execute function implementations + +The execute functions used by the `fs` commands typically accept a variadic list of "option" +functions, which are applied to a "settings" structure if provided. +This is the most complicated function type supported by the `command` package, but carries some +benefits in regards to lazy evaluation, handling of default values, and chaining shared options +across commands. + +*Note: the actual implementations are more abstracted than these examples, but the principles +remain the same.* + +```go +type ( + fooSettings struct { + value int + } + fooOption func(*fooSettings) +) + +func fooExecute(ctx context.Context, options ...fooOption) error { + settings := fooSettings{ + value: 1, // Default. + } + for _, apply := range options { + apply(&settings) // Override defaults. + } + // Execute foo command with settings... + return nil +} +``` + +This function signature can satisfy the `command.ExecuteVariadic` constraint, which allows the +`ExecuteType` to be a pointer to any underlying slice type. +To do this, we implement a method which binds to a `flag.FlagSet` on a pointer receiver that +matches the variadic type. + +```go +type fooOptions []fooOption // Same underlying type as `...fooOption`. + +// Satisfy the [command.ExecuteType] constraint. +func (fo *fooOptions) BindFlags(flagSet *flag.FlagSet) { + flagSet.Func("flag", "help text", func(parameter string) error { + value, err := parse(parameter) + // Handle err... + *fo = append(*fo, func(settings *fooSettings) { + settings.value = value + }) + return nil + }) +} +``` + +We can then call the command constructor to create a formal command. + +```go +func Foo() command.Command { + const ( + name = "foo" + synopsis = "Frobnicates a bar." + usage = "Foo command long help text..." + ) + return command.MakeVariadicCommand[fooOptions](name, synopsis, usage, execute) +} +``` + +## Client <-> Server <-> OS interactions + +![interactions](assets/interactions.svg) + +### Client commands + +Client commands are primarily only responsible for parsing the command line vector, +instantiating a connection to a server, and passing parameters to Go interfaces which give names +to common 9P operation sequences. + +If an address is not specified via the `-api-server` flag, client commands will try to connect +to a default local address. If the default server cannot initially be dialed, the client command +will automatically spawn a daemon process that will exit when it becomes idle for some time. +(See: help text for `-api-exit-after` flag) + +### Server command + +`fs daemon` is the server component which responds to requests made by client commands. +The daemon is typically run in the background and persists to facilitate longstanding operations +such as engaging with the host's file system APIs. + +When the daemon starts, it first initializes its 9P file system, then tries to serve this system +on an address. Afterwards, it launches a number of event monitors which handle tasks +like IPC communication, shutdown requests, idle detection, and more. + + +### Daemon IPC + +The daemon can communicate with a parent process using the 9P2000.L protocol over standard +input and output, as well as text over standard error. +When the parent process is finished with the service, it must write the `EOT` byte to the file +`/control/release`. + + diff --git a/doc/generate.ps1 b/doc/generate.ps1 new file mode 100644 index 00000000..da278dff --- /dev/null +++ b/doc/generate.ps1 @@ -0,0 +1,6 @@ +$moduleName = "go-fs-documentation" +$modulePath = Join-Path . "$($moduleName).psm1" + +Import-Module -Name $modulePath +New-GoFSDocumentation +Remove-Module -Name $moduleName diff --git a/doc/go-fs-documentation.psm1 b/doc/go-fs-documentation.psm1 new file mode 100644 index 00000000..4ccfc61b --- /dev/null +++ b/doc/go-fs-documentation.psm1 @@ -0,0 +1,64 @@ +function Get-GoFSDocumentationCommandTable { + $dependencies = @{ + 'go' = 'https://go.dev/' + 'goda' = 'https://github.com/loov/goda' + 'dot' = 'https://graphviz.org/' + 'd2' = 'https://github.com/terrastruct/d2' + } + $commands = @{} + foreach ($application in $dependencies.GetEnumerator()) { + $name = $application.Name + try { + $command = Get-Command -Name $name -CommandType Application -ErrorAction Stop + } catch { + throw "Required command ``$name`` was not found. See: $($dependencies[$name])" + } + $commands[$name] = $command + } + return $commands +} + +function New-GoFSDocumentation { + [CmdletBinding(SupportsShouldProcess)] + param() + + $commands = Get-GoFSDocumentationCommandTable + $resultDirectory = Join-Path . assets + [void](New-Item -ItemType Directory -ErrorAction Ignore -Path $resultDirectory) + + # Include all of our modules, but exclude the build tool. + # Note: Module separator '/', not host separator. + $godaExpression = '../... - ../cmd/build' + $dependencyGraph = 'goda-short.svg' + $dependencyGraphPath = $(Join-Path $resultDirectory $dependencyGraph) + if ($PSCmdlet.ShouldProcess($dependencyGraphPath, 'Generate dependency graph')) { + Write-Information "Generating graph: `"$($dependencyGraphPath)`"" + & $commands['goda'] graph -short -f='{{.ID}}' $godaExpression | & $commands['dot'] -Tsvg -o $dependencyGraphPath + } + + $verbose = $InformationPreference -eq [System.Management.Automation.ActionPreference]::SilentlyContinue + Get-ChildItem -Path $(Join-Path graphs *) -Include *.d2 | ForEach-Object { + $vectorName = "$($_.BaseName).svg" + $vectorPath = Join-Path $resultDirectory $vectorName + if (!$PSCmdlet.ShouldProcess($vectorPath, 'Generate vector')) { + return + } + Write-Information "Generating graph: `"$vectorPath`"" + # d2 uses stderr for all logging. + # Redirect it and only print it conditionally. + $startInfo = [System.Diagnostics.ProcessStartInfo]::new( + $commands['d2'].Source, + "`"$($_.FullName)`" `"$($vectorPath)`"" + ) + $startInfo.RedirectStandardError = $true + $process = [System.Diagnostics.Process]::Start($startInfo) + $stderr = $process.StandardError.ReadToEnd() + $process.WaitForExit() + if ($process.ExitCode) { + $sourceRelative = Resolve-Path -Relative -Path $_.FullName + Write-Error "`"$($sourceRelative)`"` -> `"$($vectorPath)`" $stderr" + } elseif ($verbose) { + Write-Information $stderr + } + } +} diff --git a/doc/graphs/filesystem.d2 b/doc/graphs/filesystem.d2 new file mode 100644 index 00000000..478f8a71 --- /dev/null +++ b/doc/graphs/filesystem.d2 @@ -0,0 +1,39 @@ +file system { + control/ { + shutdown: |md + ### Shutdown file + name: "shutdown" + write: shutdown disposition value + (string or decimal; e.g. "patient" or 1) + | + } + listeners/ { + /\.\.\.$maddr-components\.\.\./ { + socket: |md + ### Socket file + name: "listener" + read: multiaddr string + | + connections/ { + conn: |md + ### Connection file + name: ${decimal number} + read: connection metadata as JSON + | + link: schemas/connection.json + } + } + } + mounts/ { + $host-API-name/ { + $guest-API-name/ { + mount-file: |md + ### Mount point file + name: ${NanoID}.json + read: mount point metadata as JSON + | + link: schemas/mountpoint.json + } + } + } +} diff --git a/doc/graphs/interactions.d2 b/doc/graphs/interactions.d2 new file mode 100644 index 00000000..4f684f7c --- /dev/null +++ b/doc/graphs/interactions.d2 @@ -0,0 +1,45 @@ +direction: right +cli: Command Line +fs-process: | + 1st-party client process + (fs mount, etc.) +| +fs-process.shape: rectangle +ext-process: | + 3rd-party client process + (shell extension dll, etc.) +| +ext-process.shape: rectangle +fs-daemon: | + File system service + (fs daemon) +| +fs-daemon.shape: rectangle + +cli -> fs-process: Text parser +fs-process <-> fs-daemon: 9P { + style.animated:true +} +ext-process <-> fs-daemon: 9P { + style.animated:true +} +host: | + Host API + (FUSE, 9P, et al.) +| +host.shape: rectangle +guest: | + Guest API + (IPFS, PinFS, et al.) +| +guest.shape: rectangle +os: Operating system +fs-daemon <-> guest: Go API { + style.animated: true +} +fs-daemon <-> host: Go API { + style.animated: true +} +host <-> os: API|ABI { + style.animated: true +} diff --git a/doc/schemas/connection.json b/doc/schemas/connection.json new file mode 100644 index 00000000..76823a23 --- /dev/null +++ b/doc/schemas/connection.json @@ -0,0 +1,27 @@ +{ + "type": "object", + "properties": { + "lastRead": { + "type": "string" + }, + "lastWrite": { + "type": "string" + }, + "local": { + "type": "string" + }, + "remote": { + "type": "string" + }, + "#": { + "type": "integer" + } + }, + "required": [ + "lastRead", + "lastWrite", + "local", + "remote", + "#" + ] +} diff --git a/doc/schemas/mount.json b/doc/schemas/mount.json new file mode 100644 index 00000000..f7fb30f3 --- /dev/null +++ b/doc/schemas/mount.json @@ -0,0 +1,44 @@ +{ + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "host": { + "type": "object" + }, + "guest": { + "type": "object" + } + } + }, + "tag": { + "type": "object", + "properties": { + "host": { + "type": "string", + "enum": [ + "FUSE" + ] + }, + "guest": { + "type": "string", + "enum": [ + "IPFS", + "IPNS", + "PinFS", + "KeyFS" + ] + } + }, + "required": [ + "host", + "guest" + ] + } + }, + "required": [ + "data", + "tag" + ] +} diff --git a/doc/usage.md b/doc/usage.md new file mode 100644 index 00000000..70666c06 --- /dev/null +++ b/doc/usage.md @@ -0,0 +1,155 @@ +# Usage + +## Command line + +### Conventions and autonomy + +#### Flags + +Command line flags use the [Go standard +convention](https://pkg.go.dev/flag#hdr-Command_line_flag_syntax) and `fs` provides a `-help` +flag for each (sub)command. +Flags and their default values may differ across operating systems. + +#### Client and server commands + +Most subcommands are client commands that connect to the file system service daemon. +If the service daemon is not running, and a client command did not specify the `-api-server` +flag, the client process will try to invoke the `fs daemon` command automatically. +That process will periodically check if it's no longer needed and exit if that's the case. To +set the idle check interval for that process, client commands can provide the `-api-exit-after` +flag. + +### cmd/build + +`cmd/build` attempts to set up a process environment before invoking the `go` tool to build +`cmd/fs`. +`cmd/fs` has the same requirements as [cgofuse](https://github.com/winfsp/cgofuse#how-to-build), +so these must be installed for `cmd/build` to succeed. + +The build command is typically executed via `go run ./cmd/build` from the root of the source +directory. +Different build modes may be specified via the `-mode` flag. By default, `cmd/build` will try to +build in "release" mode, which produces a smaller binary. + +--- + +If you'd like to build `cmd/fs` manually, you can invoke `go build` on the `cmd/fs` command, +but must make sure the compiler used by CGO can find the FUSE library headers. + +On Windows with the default WinFSP install path, you can set the `CPATH` environment variable +like this: + +```pwsh +$ENV:CPATH = $(Join-Path (${ENV:ProgramFiles(x86)} ?? ${ENV:ProgramFiles}) "WinFsp" "inc" "fuse") +go build .\cmd\fs +``` + +POSIX systems are expected to have libraries in a standard path, +but may be specified in a similar way: + +```sh +CPATH=/path/to/libfuse go build ./cmd/fs +``` + +### cmd/fs + +`cmd/fs` is the main command used to interact with file system services. + +#### mount | unmount + +Generally, you'll want to mount a system using this pattern: +`fs $hostAPI $guestAPI $mountPoint` + +And for unmounting: +`fs unmount $mountPoint` or `fs unmount -all` + +For both `mount` and `unmount`, multiple mount points may be specified in a single invocation. + +On POSIX systems, valid mount points are typically (existing) directories. +E.g. `fs mount fuse ipfs /mnt/ipfs`. +On NT systems, valid mount points may be drive letters `X:`, non-existing paths `C:\mountpoint`, +or UNC location `\\Server\Share`. +E.g. `fs mount fuse ipfs I: C:\ipfs \\localhost\ipfs` + +Each pair of host and guest APIs may have its own set of command line flags and constraints that +should be outlined their `-help` text if applicable. + +#### daemon + +The file system service daemon is typically summoned automatically by client commands, +but may be invoked separately via the `fs daemon` command. +The `-api-server=$multiaddr` flag may be provided to the daemon to specify +which address(es) it will listen on. +The same flag may be provided to client commands to specify which service address to use. + +#### shutdown + +The daemon can be requested to stop via `fs shutdown`. +By default a "patient" request is sent, which prevents new connections from being established, +and closes existing connections after they're considered idle. +When all connections are closed, any active mounts are unmounted and the process exits. + +Alternate shutdown dispositions may be provided via the `-level` flag. +Such as "short" which will close existing connection after some short time (regardless of if +they're idle or not). +And "immediate" which closes existing connections immediately. + +Note that `shutdown` only requests a shutdown, it does not wait for the shutdown process to +finish. + +## 9P API +The 9P API is not yet stable or well documented, but is the primary interface used by the `fs` +client commands. + +The `fs daemon` is a 9P file system server which listens on the multiaddrs provided by +the `-api-server` flag, which is where the API is to be exposed. + +![file system](assets/filesystem.svg) +(\*The SVG above contains hyperlinks to schemas for the JSON being referred to.) + +The `-verbose` flag may be provided when invoking commands. This prints out the 9P messages sent +and received from both the client and server. This effectively traces the protocol which is +useful for understanding the expected sequence, and for debugging. + +--- + +External processes may choose to interact with the API by connecting to it and sending messages +that adhere to the [9P2000.L +specification](https://github.com/chaos/diod/blob/master/protocol.md). +Common external clients are the operating system itself. Such as NT, Linux, Plan 9, et +al. + +Here is one such example of replicating the `fs mount` and `fs unmount` commands via a POSIX +shell. + +```sh +# Start the daemon process in the background. +fs daemon -api-server /ip4/192.168.1.40/tcp/564 & +# Mount the 9P API +mount -t 9p 192.168.1.40 /mnt/9 -o "trans=tcp,port=564" +# Create the mount point's metadata path; populate the mount metdata in "field mode" via a "here document". +# This is equivalent to calling `fs mount fuse pinfs -ipfs=/ip4/192.168.1.40/tcp/5001 /mnt/ipfs`. +mkdir -p /mnt/9/mounts/FUSE/PinFS; cat << EOF > /mnt/9/mounts/FUSE/PinFS/mountpoint +host.point /mnt/ipfs +guest.apiMaddr /ip4/192.168.1.40/tcp/5001 +EOF +# Mount point metadata is formated as JSON when read back. +# We can back up this virtual file to a real on-disk file. +cp /mnt/9/mount/FUSE/PinFs/mountpoint ~/mountpoint.json +# Removing the metadata file is equivalent to calling `fs unmount /mnt/ipfs`. +rm /mnt/9/mount/FUSE/PinFs/mountpoint +# When directories are empty, under certain circumstances they will be unlinked automatically. +# In this case we need to create the path again. +# Previously we created the file by writing field data line by line, but JSON is also accepted. +# So this too is equivalent to calling `fs mount fuse pinfs -ipfs=/ip4/192.168.1.40/tcp/5001 /mnt/ipfs`. +mkdir -p /mnt/9/mounts/FUSE/PinFS; cp ~/mountpoint.json /mnt/9/mounts/FUSE/Pinfs/same-one.json +``` + +--- + +In the future, file systems will likely expose their own documentation as a readable file, +similar to how commands contain their own help text. +I.e. `cat /mounts/manual`, `cat /mounts/FUSE/PinFS/manual`, etc. + + diff --git a/go.mod b/go.mod index 2a503c60..f959a1e4 100644 --- a/go.mod +++ b/go.mod @@ -1,36 +1,44 @@ module github.com/djdv/go-filesystem-utils -go 1.19 +go 1.20 require ( github.com/adrg/xdg v0.4.0 + github.com/charmbracelet/glamour v0.6.0 + github.com/djdv/p9 v0.2.1-0.20230601152255-7d74b92b80b0 github.com/hashicorp/golang-lru/v2 v2.0.2 - github.com/hugelgupf/p9 v0.0.0-00010101000000-000000000000 github.com/ipfs/boxo v0.10.2-0.20230629143123-2d3edc552442 github.com/ipfs/go-cid v0.4.1 github.com/ipfs/go-ipfs-cmds v0.9.0 - github.com/ipfs/go-ipfs-http-client v0.5.1-0.20230418133148-ae996cbe5a91 github.com/ipfs/go-ipld-cbor v0.0.6 github.com/ipfs/go-ipld-format v0.5.0 github.com/ipfs/kubo v0.21.0 github.com/jaevor/go-nanoid v1.3.0 + github.com/mattn/go-colorable v0.1.4 + github.com/muesli/termenv v0.15.1 github.com/multiformats/go-multiaddr v0.9.0 github.com/multiformats/go-multiaddr-dns v0.3.1 github.com/multiformats/go-multibase v0.2.0 - github.com/u-root/uio v0.0.0-20220204230159-dac05f7d2cb4 + github.com/u-root/uio v0.0.0-20230305220412-3e8cd9d6bf63 github.com/winfsp/cgofuse v1.5.1-0.20230130140708-f87f5db493b5 golang.org/x/exp v0.0.0-20230321023759-10a507213a29 - + golang.org/x/sys v0.9.0 + golang.org/x/term v0.9.0 ) require ( + github.com/alecthomas/chroma v0.10.0 // indirect github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect github.com/crackcomm/go-gitignore v0.0.0-20170627025303-887ab5e44cc3 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect + github.com/dlclark/regexp2 v1.4.0 // indirect github.com/go-logr/logr v1.2.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/uuid v1.3.0 // indirect + github.com/gorilla/css v1.0.0 // indirect github.com/hashicorp/golang-lru v0.5.4 // indirect github.com/ipfs/bbloom v0.0.4 // indirect github.com/ipfs/go-bitfield v1.1.0 // indirect @@ -47,23 +55,32 @@ require ( github.com/klauspost/cpuid/v2 v2.2.5 // indirect github.com/libp2p/go-buffer-pool v0.1.0 // indirect github.com/libp2p/go-libp2p v0.27.7 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/lxn/win v0.0.0-20210218163916-a377121e959e github.com/mattn/go-isatty v0.0.19 // indirect + github.com/mattn/go-runewidth v0.0.14 // indirect + github.com/microcosm-cc/bluemonday v1.0.21 // indirect github.com/miekg/dns v1.1.54 // indirect github.com/minio/sha256-simd v1.0.1 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mr-tron/base58 v1.2.0 // indirect + github.com/muesli/reflow v0.3.0 // indirect github.com/multiformats/go-base32 v0.1.0 // indirect github.com/multiformats/go-base36 v0.2.0 // indirect github.com/multiformats/go-multicodec v0.9.0 // indirect github.com/multiformats/go-multihash v0.2.3 // indirect github.com/multiformats/go-multistream v0.4.1 // indirect github.com/multiformats/go-varint v0.0.7 // indirect + github.com/olekukonko/tablewriter v0.0.5 // indirect github.com/opentracing/opentracing-go v1.2.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/polydawn/refmt v0.89.0 // indirect + github.com/rivo/uniseg v0.2.0 // indirect github.com/rs/cors v1.7.0 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/whyrusleeping/cbor-gen v0.0.0-20230126041949-52956bd4c9aa // indirect + github.com/yuin/goldmark v1.5.2 // indirect + github.com/yuin/goldmark-emoji v1.0.1 // indirect go.opentelemetry.io/otel v1.16.0 // indirect go.opentelemetry.io/otel/metric v1.16.0 // indirect go.opentelemetry.io/otel/trace v1.16.0 // indirect @@ -74,13 +91,9 @@ require ( golang.org/x/mod v0.10.0 // indirect golang.org/x/net v0.10.0 // indirect golang.org/x/sync v0.2.0 // indirect - golang.org/x/sys v0.9.0 // indirect golang.org/x/tools v0.9.1 // indirect golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect google.golang.org/protobuf v1.30.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect lukechampine.com/blake3 v1.2.1 // indirect ) - -// FIXME: Ideally we remove this replace directive when upstream merges -// if that doesn't happen before end of review, we'll have to fork. -replace github.com/hugelgupf/p9 => github.com/djdv/p9 v0.2.1-0.20221024045104-6b0c9ca47f00 diff --git a/go.sum b/go.sum index 7699a7a3..8f440922 100644 --- a/go.sum +++ b/go.sum @@ -1,138 +1,85 @@ -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/adrg/xdg v0.4.0 h1:RzRqFcjH4nE5C6oTAxhBtoE2IRyjBSa62SCbyPidvls= github.com/adrg/xdg v0.4.0/go.mod h1:N6ag73EX4wyxeaoeHctc1mas01KZgsj5tYiAIwqJE/E= -github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek= +github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s= github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 h1:s6gZFSlWYmbqAuRjVTiNNhvNRfY2Wxp9nhfyel4rklc= github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= -github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= -github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= -github.com/beevik/ntp v0.3.0/go.mod h1:hIHWr+l3+/clUnF44zdK+CWW7fO8dR5cIylAQ76NRpg= +github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= -github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A= -github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= -github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= -github.com/cenkalti/backoff/v4 v4.0.2/go.mod h1:eEew/i+1Q6OrCDZh3WiXYv3+nJwBASZ8Bog/87DQnVg= -github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= -github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= -github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= -github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= +github.com/charmbracelet/glamour v0.6.0 h1:wi8fse3Y7nfcabbbDuwolqTqMQPMnVPeZhDM273bISc= +github.com/charmbracelet/glamour v0.6.0/go.mod h1:taqWV4swIMMbWALc0m7AfE9JkPSU8om2538k9ITBxOc= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/crackcomm/go-gitignore v0.0.0-20170627025303-887ab5e44cc3 h1:HVTnpeuvF6Owjd5mniCL8DEXo7uYXdQEmOP4FJbV5tg= github.com/crackcomm/go-gitignore v0.0.0-20170627025303-887ab5e44cc3/go.mod h1:p1d6YEZWvFzEh4KLyvBcVSnrfNDDvK2zfK/4x2v/4pE= -github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= -github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/cskr/pubsub v1.0.2 h1:vlOzMhl6PFn60gRlTQQsIfVwaPB/B/8MziK8FhEPt/0= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c h1:pFUpOrbxDR6AkioZ1ySsx5yxlDQZ8stG2b88gTPxgJU= -github.com/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0 h1:HbphB4TFFXpv7MNrT52FGrrgVXF1owhMVTHFZIlnvd4= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0/go.mod h1:DZGJHZMqrU4JJqFAWUS2UO1+lbSKsdiOoYi9Zzey7Fc= +github.com/decred/dcrd/crypto/blake256 v1.0.1 h1:7PltbUIQB7u/FfZ39+DGa/ShuMyJ5ilcvdfma9wOH6Y= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= -github.com/djdv/p9 v0.2.1-0.20221024045104-6b0c9ca47f00 h1:cyQBP3qffx85u62Pufv/GAK05gUwtDSR3KVovHC8sNM= -github.com/djdv/p9 v0.2.1-0.20221024045104-6b0c9ca47f00/go.mod h1:d5H7Bc5VS6NgKyPmAGLB6qMj5rvRpP/u0HaVI5pp7kY= -github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= -github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/djdv/p9 v0.2.1-0.20230601152255-7d74b92b80b0 h1:TmRbQZzEz+AbtudHs+4OtcggEd6mgbcf1UA3DdUMg/M= +github.com/djdv/p9 v0.2.1-0.20230601152255-7d74b92b80b0/go.mod h1:TGzUXNk2SONYuJnhbmn6w308jdHeBqWwQUqr3yng5XQ= +github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E= +github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/facebookgo/atomicfile v0.0.0-20151019160806-2de1f203e7d5 h1:BBso6MBKW8ncyZLv37o+KNyy0HrrHgfnOaGQC2qvN+A= -github.com/fanliao/go-promise v0.0.0-20141029170127-1890db352a72/go.mod h1:PjfxuH4FZdUyfMdtBio2lsRr1AKEaVPwelzuHuh8Lqc= github.com/flynn/noise v1.0.0 h1:DlTHqmzmvcEiKj+4RYo/imoswx/4r6iBlCMfVtrMXpQ= github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk= -github.com/frankban/quicktest v1.11.3/go.mod h1:wRf/ReqHper53s+kmmSZizM8NamnL3IM0I9ntUbOk+k= github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY= -github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/gliderlabs/ssh v0.1.2-0.20181113160402-cbabf5414432/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= -github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0= -github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= -github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/gojuno/minimock/v3 v3.0.4/go.mod h1:HqeqnwV8mAABn3pO5hqF+RE7gjA0jsN8cbbSogoGrzI= -github.com/gojuno/minimock/v3 v3.0.8/go.mod h1:TPKxc8tiB8O83YH2//pOzxvEjaI3TMhd6ev/GmlMiYA= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-tpm v0.1.2-0.20190725015402-ae6dd98980d4/go.mod h1:H9HbmUG2YgV/PHITkO7p6wxEEj/v5nlsVWIwumwH2NI= -github.com/google/go-tpm v0.2.1-0.20200615092505-5d8a91de9ae3/go.mod h1:iVLWvrPp/bHeEkxTFi9WG6K9w0iy2yIszHwZGHPbzAw= -github.com/google/go-tpm-tools v0.0.0-20190906225433-1614c142f845/go.mod h1:AVfHadzbdzHo54inR2x1v640jdi1YSi3NauM2DUsxk0= -github.com/google/goexpect v0.0.0-20191001010744-5b6988669ffa/go.mod h1:qtE5aAEkt0vOSA84DBh8aJsz6riL8ONfqfULY7lBjqc= github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= -github.com/google/goterm v0.0.0-20190703233501-fc88cf888a3f/go.mod h1:nOFQdrUlIlx6M6ODdSpBj1NVA+VgLC6kmw60mkw34H4= -github.com/google/pprof v0.0.0-20230405160723-4a4c7d95572b h1:Qcx5LM0fSiks9uCyFZwDBUasd3lxd1RM0GYpL+Li5o4= +github.com/google/pprof v0.0.0-20230602150820-91b7bce49751 h1:hR7/MlvK23p6+lIw9SN1TigNLn9ZnF3W4SYRKq2gAHs= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c h1:7lF+Vz0LqiRidnzC1Oq86fpX1q/iEv2KJdrCtttYjT4= +github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= +github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= github.com/gxed/hashland/keccakpg v0.0.1/go.mod h1:kRzw3HkwxFU1mpmPP8v1WyQzwdGfmKFJ6tItnhQ67kU= github.com/gxed/hashland/murmur3 v0.0.1/go.mod h1:KjXop02n4/ckmZSnY2+HKcLud/tcmvhST0bie/0lS48= github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/golang-lru/v2 v2.0.2 h1:Dwmkdr5Nc/oBiXgJS3CDHNhJtIHkuZ3DZF5twqnfBdU= github.com/hashicorp/golang-lru/v2 v2.0.2/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/hexdigest/gowrap v1.1.7/go.mod h1:Z+nBFUDLa01iaNM+/jzoOA1JJ7sm51rnYFauKFUB5fs= -github.com/hexdigest/gowrap v1.1.8/go.mod h1:H/JiFmQMp//tedlV8qt2xBdGzmne6bpbaSuiHmygnMw= github.com/hugelgupf/socketpair v0.0.0-20190730060125-05d35a94e714 h1:/jC7qQFrv8CrSJVmaolDVOxTfS9kc36uB6H40kdbQq8= -github.com/hugelgupf/socketpair v0.0.0-20190730060125-05d35a94e714/go.mod h1:2Goc3h8EklBH5mspfHFxBnEoURQCGzQQH1ga9Myjvis= -github.com/huin/goupnp v1.1.0 h1:gEe0Dp/lZmPZiDFzJJaOfUpOvv2MKUkoBX8lDrn9vKU= -github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= -github.com/insomniacslk/dhcp v0.0.0-20210817203519-d82598001386/go.mod h1:h+MxyHxRg9NH3terB1nfRIUaQEcI0XOVkdR9LNBlp8E= -github.com/intel-go/cpuid v0.0.0-20200819041909-2aa72927c3e2/go.mod h1:RmeVYf9XrPRbRc3XIx0gLYA8qOFvNoPOfaEZduRlEp4= +github.com/huin/goupnp v1.2.0 h1:uOKW26NG1hsSSbXIZ1IR7XP9Gjd1U8pnLaCMgntmkmY= github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs= github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0= -github.com/ipfs/boxo v0.8.1 h1:3DkKBCK+3rdEB5t77WDShUXXhktYwH99mkAsgajsKrU= -github.com/ipfs/boxo v0.8.1/go.mod h1:xJ2hVb4La5WyD7GvKYE0lq2g1rmQZoCD2K4WNrV6aZI= github.com/ipfs/boxo v0.10.2-0.20230629143123-2d3edc552442 h1:SGbw381zt6c1VFf3QCBaJ+eVJ4AwD9fPaFKFp9U9Apk= github.com/ipfs/boxo v0.10.2-0.20230629143123-2d3edc552442/go.mod h1:1qgKq45mPRCxf4ZPoJV2lnXxyxucigILMJOrQrVivv8= github.com/ipfs/go-bitfield v1.1.0 h1:fh7FIo8bSwaJEh6DdTWbCeZ1eqOaOkKFI74SCnsWbGA= github.com/ipfs/go-bitfield v1.1.0/go.mod h1:paqf1wjq/D2BBmzfTVFlJQ9IlFOZpg422HL0HqsGWHU= github.com/ipfs/go-block-format v0.0.2/go.mod h1:AWR46JfpcObNfg3ok2JHDUfdiHRgWhJgCQF+KIgOPJY= -github.com/ipfs/go-block-format v0.0.3/go.mod h1:4LmD4ZUw0mhO+JSKdpWwrzATiEfM7WWgQ8H5l6P8MVk= github.com/ipfs/go-block-format v0.1.2 h1:GAjkfhVx1f4YTODS6Esrj1wt2HhrtwTnhEr+DyPUaJo= github.com/ipfs/go-block-format v0.1.2/go.mod h1:mACVcrxarQKstUU3Yf/RdwbC4DzPV6++rO2a3d+a/KE= github.com/ipfs/go-cid v0.0.1/go.mod h1:GHWU/WuQdMPmIosc4Yn1bcCT7dSeX4lBafM7iqUPQvM= -github.com/ipfs/go-cid v0.0.2/go.mod h1:GHWU/WuQdMPmIosc4Yn1bcCT7dSeX4lBafM7iqUPQvM= github.com/ipfs/go-cid v0.0.3/go.mod h1:GHWU/WuQdMPmIosc4Yn1bcCT7dSeX4lBafM7iqUPQvM= -github.com/ipfs/go-cid v0.0.4/go.mod h1:4LLaPOQwmk5z9LBgQnpkivrx8BJjUyGwTXCd5Xfj6+M= github.com/ipfs/go-cid v0.0.6/go.mod h1:6Ux9z5e+HpkQdckYoX1PG/6xqKspzlEIR5SDmgqgC/I= -github.com/ipfs/go-cid v0.0.7/go.mod h1:6Ux9z5e+HpkQdckYoX1PG/6xqKspzlEIR5SDmgqgC/I= github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s= github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk= github.com/ipfs/go-datastore v0.6.0 h1:JKyz+Gvz1QEZw0LsX1IBn+JFCJQH4SJVFtM4uWU0Myk= @@ -142,24 +89,15 @@ github.com/ipfs/go-ipfs-blocksutil v0.0.1 h1:Eh/H4pc1hsvhzsQoMEP3Bke/aW5P5rVM1IW github.com/ipfs/go-ipfs-cmds v0.9.0 h1:K0VcXg1l1k6aY6sHnoxYcyimyJQbcV1ueXuWgThmK9Q= github.com/ipfs/go-ipfs-cmds v0.9.0/go.mod h1:SBFHK8WNwC416QWH9Vz1Ql42SSMAOqKpaHUMBu3jpLo= github.com/ipfs/go-ipfs-delay v0.0.1 h1:r/UXYyRcddO6thwOnhiznIAiSvxMECGgtv35Xs1IeRQ= -github.com/ipfs/go-ipfs-http-client v0.5.1-0.20230418133148-ae996cbe5a91 h1:MEfl5J8GY24pVDNicmo39cNquIMtMJEum6Ph3682AMY= -github.com/ipfs/go-ipfs-http-client v0.5.1-0.20230418133148-ae996cbe5a91/go.mod h1:/DmMMd2rkgT1oDpSvKFtk1rti6sZAiviAVroJ2LdmIQ= github.com/ipfs/go-ipfs-pq v0.0.3 h1:YpoHVJB+jzK15mr/xsWC574tyDLkezVrDNeaalQBsTE= github.com/ipfs/go-ipfs-util v0.0.1/go.mod h1:spsl5z8KUnrve+73pOhSVZND1SIxPW5RyBCNzQxlJBc= -github.com/ipfs/go-ipfs-util v0.0.2 h1:59Sswnk1MFaiq+VcaknX7aYEyGyGDAA73ilhEK2POp8= -github.com/ipfs/go-ipfs-util v0.0.2/go.mod h1:CbPtkWJzjLdEcezDns2XYaehFVNXG9zrdrtMecczcsQ= github.com/ipfs/go-ipfs-util v0.0.3 h1:2RFdGez6bu2ZlZdI+rWfIdbQb1KudQp3VGwPtdNCmE0= github.com/ipfs/go-ipfs-util v0.0.3/go.mod h1:LHzG1a0Ig4G+iZ26UUOMjHd+lfM84LZCrn17xAKWBvs= github.com/ipfs/go-ipld-cbor v0.0.6 h1:pYuWHyvSpIsOOLw4Jy7NbBkCyzLDcl64Bf/LZW7eBQ0= github.com/ipfs/go-ipld-cbor v0.0.6/go.mod h1:ssdxxaLJPXH7OjF5V4NSjBbcfh+evoR4ukuru0oPXMA= github.com/ipfs/go-ipld-format v0.0.1/go.mod h1:kyJtbkDALmFHv3QR6et67i35QzO3S0dCDnkOJhcZkms= -github.com/ipfs/go-ipld-format v0.2.0/go.mod h1:3l3C1uKoadTPbeNfrDi+xMInYKlx2Cvg1BuydPSdzQs= -github.com/ipfs/go-ipld-format v0.4.0 h1:yqJSaJftjmjc9jEOFYlpkwOLVKv68OD27jFLlSghBlQ= -github.com/ipfs/go-ipld-format v0.4.0/go.mod h1:co/SdBE8h99968X0hViiw1MNlh6fvxxnHpvVLnH7jSM= github.com/ipfs/go-ipld-format v0.5.0 h1:WyEle9K96MSrvr47zZHKKcDxJ/vlpET6PSiQsAFO+Ds= github.com/ipfs/go-ipld-format v0.5.0/go.mod h1:ImdZqJQaEouMjCvqCe0ORUS+uoBmf7Hf+EO/jh+nk3M= -github.com/ipfs/go-ipld-legacy v0.1.1 h1:BvD8PEuqwBHLTKqlGFTHSwrwFOMkVESEvwIYwR2cdcc= -github.com/ipfs/go-ipld-legacy v0.1.1/go.mod h1:8AyKFCjgRPsQFf15ZQgDB8Din4DML/fOmKZkkFkrIEg= github.com/ipfs/go-ipld-legacy v0.2.1 h1:mDFtrBpmU7b//LzLSypVrXsD8QxkEWxu5qVxN99/+tk= github.com/ipfs/go-ipld-legacy v0.2.1/go.mod h1:782MOUghNzMO2DER0FlBR94mllfdCJCkTtDtPM51otM= github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8= @@ -170,15 +108,10 @@ github.com/ipfs/go-log/v2 v2.5.1/go.mod h1:prSpmC1Gpllc9UYWxDiZDreBYw7zp4Iqp1kOL github.com/ipfs/go-metrics-interface v0.0.1 h1:j+cpbjYvu4R8zbleSs36gvB7jR+wsL2fGD6n0jO4kdg= github.com/ipfs/go-metrics-interface v0.0.1/go.mod h1:6s6euYU4zowdslK0GKHmqaIZ3j/b/tL7HTWtJ4VPgWY= github.com/ipfs/go-peertaskqueue v0.8.1 h1:YhxAs1+wxb5jk7RvS0LHdyiILpNmRIRnZVztekOF0pg= -github.com/ipfs/iptb v1.4.0 h1:YFYTrCkLMRwk/35IMyC6+yjoQSHTEcNcefBStLJzgvo= -github.com/ipfs/iptb-plugins v0.5.0 h1:zEMLlWAb531mLpD36KFy/yc0egT6FkBEHQtdERexNao= -github.com/ipfs/kubo v0.20.0 h1:bnURAj3pBcz4Mu5Z3OrWNvXl22/Y2xGKIJcStc9jGOA= -github.com/ipfs/kubo v0.20.0/go.mod h1:f9gTqR5sgz4VoAm6ZJsaFu7SivVZPRrOHtrDdI9Brow= github.com/ipfs/kubo v0.21.0 h1:1+XKokeyatfI2Mri5iBn8Eplxf2F5ud0K5zLDg4tSSc= github.com/ipfs/kubo v0.21.0/go.mod h1:LuK5ANXz/UhHp8jdt4mRwE16AHhbRqY68g/uyc5/VqU= github.com/ipld/go-codec-dagpb v1.6.0 h1:9nYazfyu9B1p3NAgfVdpRco3Fs2nFC72DqVsMj6rOcc= github.com/ipld/go-codec-dagpb v1.6.0/go.mod h1:ANzFhfP2uMJxRBr8CE+WQWs5UsNa0pYtmKZ+agnUw9s= -github.com/ipld/go-ipld-prime v0.9.1-0.20210324083106-dc342a9917db/go.mod h1:KvBLMr4PX1gWptgkzRjVZCrLmSGcZCb/jioOQwCqZN8= github.com/ipld/go-ipld-prime v0.20.0 h1:Ud3VwE9ClxpO2LkCYP7vWPc0Fo+dYdYzgxUJZ3uRG4g= github.com/ipld/go-ipld-prime v0.20.0/go.mod h1:PzqZ/ZR981eKbgdr3y2DJYeD/8bgMawdGVlJDE8kK+M= github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= @@ -188,43 +121,25 @@ github.com/jbenet/go-cienv v0.1.0/go.mod h1:TqNnHUmJgXau0nCzC7kXWeotg3J9W34CUv5D github.com/jbenet/go-temp-err-catcher v0.1.0 h1:zpb3ZH6wIE8Shj2sKS+khgRvf7T7RABoLk/+KKHggpk= github.com/jbenet/goprocess v0.1.4 h1:DRGOFReOMqqDNXwW70QkacFW0YN9QnwLV0Vqk+3oU0o= github.com/jbenet/goprocess v0.1.4/go.mod h1:5yspPrukOVuOLORacaBi858NqyClJPQxYZlqdZVfqY4= -github.com/jsimonetti/rtnetlink v0.0.0-20190606172950-9527aa82566a/go.mod h1:Oz+70psSo5OFh8DBl0Zv2ACw7Esh6pPUphlvZG9x7uw= -github.com/jsimonetti/rtnetlink v0.0.0-20200117123717-f846d4f6c1f4/go.mod h1:WGuG/smIU4J/54PblvSbh+xvCZmpJnFgr3ds6Z55XMQ= -github.com/jsimonetti/rtnetlink v0.0.0-20201009170750-9c6f07d100c1/go.mod h1:hqoO/u39cqLeBLebZ8fWdE96O7FxrAsRYhnVOdgHxok= -github.com/jsimonetti/rtnetlink v0.0.0-20201110080708-d2c240429e6c/go.mod h1:huN4d1phzjhlOsNIjFsw2SVRbwIHj3fJDMEU2SDPTmg= -github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/josharian/native v1.0.1-0.20221213033349-c1e37c09b531/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= -github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= -github.com/kaey/framebuffer v0.0.0-20140402104929-7b385489a1ff/go.mod h1:tS4qtlcKqtt3tCIHUflVSqeP3CLH5Qtv2szX9X2SyhU= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.10.6/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= -github.com/klauspost/compress v1.16.4 h1:91KN02FnsOYhuunwU4ssRe8lc2JosWmizWa91B5v1PU= -github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= -github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= +github.com/klauspost/compress v1.16.5 h1:IFV2oUNUzZaz+XyusxpLzpzS8Pt5rh0Z16For/djlyI= github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg= github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= -github.com/klauspost/pgzip v1.2.4/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= -github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/koron/go-ssdp v0.0.4 h1:1IDwrghSKYM7yLf7XCzbByg2sJ/JcNOZRXS2jczTwz0= -github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/libp2p/go-buffer-pool v0.0.2/go.mod h1:MvaB6xw5vOrDl8rYZGLFdKAuk/hRoRZd1Vi32+RXyFM= github.com/libp2p/go-buffer-pool v0.1.0 h1:oK4mSFcQz7cTQIfqbe4MIj9gLW+mnanjyFtc6cdF0Y8= github.com/libp2p/go-buffer-pool v0.1.0/go.mod h1:N+vh8gMqimBzdKkSMVuydVDq+UV5QTWy5HSiZacSbPg= github.com/libp2p/go-cidranger v1.1.0 h1:ewPN8EZ0dd1LSnrtuwd4709PXVcITVeuwbag38yPW7c= -github.com/libp2p/go-libp2p v0.27.3 h1:tkV/zm3KCZ4R5er9Xcs2pt0YNB4JH0iBfGAtHJdLHRs= -github.com/libp2p/go-libp2p v0.27.3/go.mod h1:FAvvfQa/YOShUYdiSS03IR9OXzkcJXwcNA2FUCh9ImE= github.com/libp2p/go-libp2p v0.27.7 h1:nhMs03CRxslKkkK2uLuN8f72uwNkE6RJS1JFb3H9UIQ= github.com/libp2p/go-libp2p v0.27.7/go.mod h1:oMfQGTb9CHnrOuSM6yMmyK2lXz3qIhnkn2+oK3B1Y2g= github.com/libp2p/go-libp2p-asn-util v0.3.0 h1:gMDcMyYiZKkocGXDQ5nsUQyquC9+H+iLEQHwOCZ7s8s= @@ -233,43 +148,44 @@ github.com/libp2p/go-libp2p-testing v0.12.0 h1:EPvBb4kKMWO29qP4mZGyhVzUyR25dvfUI github.com/libp2p/go-msgio v0.3.0 h1:mf3Z8B1xcFN314sWX+2vOTShIE0Mmn2TXn3YCUQGNj0= github.com/libp2p/go-nat v0.1.0 h1:MfVsH6DLcpa04Xr+p8hmVRG4juse0s3J8HyNWYHffXg= github.com/libp2p/go-netroute v0.2.1 h1:V8kVrpD8GK0Riv15/7VN6RbUQ3URNZVosw7H2v9tksU= -github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= -github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lxn/win v0.0.0-20210218163916-a377121e959e h1:H+t6A/QJMbhCSEH5rAuRxh+CtW96g0Or0Fxa9IKr4uc= +github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk= +github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA= +github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= -github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= -github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= +github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= -github.com/mdlayher/ethernet v0.0.0-20190606142754-0394541c37b7/go.mod h1:U6ZQobyTjI/tJyq2HG+i/dfSoFUt8/aZCM+GKtmFk/Y= -github.com/mdlayher/netlink v0.0.0-20190409211403-11939a169225/go.mod h1:eQB3mZE4aiYnlUsyGGCOpPETfdQq4Jhsgf1fk3cwQaA= -github.com/mdlayher/netlink v1.0.0/go.mod h1:KxeJAFOFLG6AjpyDkQ/iIhxygIUKD+vcwqcnu43w/+M= -github.com/mdlayher/netlink v1.1.0/go.mod h1:H4WCitaheIsdF9yOYu8CFmCgQthAPIWZmcKp9uZHgmY= -github.com/mdlayher/netlink v1.1.1/go.mod h1:WTYpFb/WTvlRJAyKhZL5/uy69TDDpHHu2VZmb2XgV7o= -github.com/mdlayher/raw v0.0.0-20190606142536-fef19f00fc18/go.mod h1:7EpbotpCmVZcu+KCX4g9WaRNuu11uyhiW7+Le1dKawg= -github.com/mdlayher/raw v0.0.0-20191009151244-50f2db8cc065/go.mod h1:7EpbotpCmVZcu+KCX4g9WaRNuu11uyhiW7+Le1dKawg= +github.com/microcosm-cc/bluemonday v1.0.21 h1:dNH3e4PSyE4vNX+KlRGHT5KrSvjeUkoNPwEORjffHJg= +github.com/microcosm-cc/bluemonday v1.0.21/go.mod h1:ytNkv4RrDrLJ2pqlsSI46O6IVXmZOBBD4SaJyDwwTkM= github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= -github.com/miekg/dns v1.1.53 h1:ZBkuHr5dxHtB1caEOlZTLPo7D3L3TWckgUUs/RHfDxw= -github.com/miekg/dns v1.1.53/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY= +github.com/miekg/dns v1.1.54 h1:5jon9mWcb0sFJGpnI99tOMhCPyJ+RPVz5b63MQG0VWI= github.com/miekg/dns v1.1.54/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY= github.com/minio/blake2b-simd v0.0.0-20160723061019-3f5f724cb5b1/go.mod h1:pD8RvIylQ358TN4wwqatJ8rNavkEINozVn9DtGI3dfQ= github.com/minio/sha256-simd v0.0.0-20190131020904-2d45a736cd16/go.mod h1:2FMWW+8GMoPweT6+pI63m9YE3Lmw4J71hV56Chs1E/U= github.com/minio/sha256-simd v0.1.1-0.20190913151208-6de447530771/go.mod h1:B5e1o+1/KgNmWrSQK08Y6Z1Vb5pwIktudl0J58iy0KM= -github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g= -github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM= github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/mr-tron/base58 v1.1.0/go.mod h1:xcD2VGqlgYjBdcBLw+TuYLr8afG+Hj8g2eTVqeSzSU8= github.com/mr-tron/base58 v1.1.2/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= github.com/mr-tron/base58 v1.1.3/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.13.0/go.mod h1:sP1+uffeLaEYpyOTb8pLCUctGcGLnoFjSn4YJK5e2bc= +github.com/muesli/termenv v0.15.1 h1:UzuTb/+hhlBugQz28rpzey4ZuKcZ03MeKsoG7IJZIxs= +github.com/muesli/termenv v0.15.1/go.mod h1:HeAQPTzpfs016yGtA4g00CsdYnVLJvxsS4ANqrZs2sQ= github.com/multiformats/go-base32 v0.0.3/go.mod h1:pLiuGC8y0QR3Ue4Zug5UzK9LjgbkL8NSQj0zQ5Nz/AA= github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= @@ -286,118 +202,72 @@ github.com/multiformats/go-multibase v0.0.1/go.mod h1:bja2MqRZ3ggyXtZSEDKpl0uO/g github.com/multiformats/go-multibase v0.0.3/go.mod h1:5+1R4eQrT3PkYZ24C3W2Ue2tPwIdYQD509ZjSb5y9Oc= github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk= -github.com/multiformats/go-multicodec v0.8.1 h1:ycepHwavHafh3grIbR1jIXnKCsFm0fqsfEOsJ8NtKE8= -github.com/multiformats/go-multicodec v0.8.1/go.mod h1:L3QTQvMIaVBkXOXXtVmYE+LI16i14xuaojr/H7Ai54k= github.com/multiformats/go-multicodec v0.9.0 h1:pb/dlPnzee/Sxv/j4PmkDRxCOi3hXTz3IbPKOXWJkmg= github.com/multiformats/go-multicodec v0.9.0/go.mod h1:L3QTQvMIaVBkXOXXtVmYE+LI16i14xuaojr/H7Ai54k= github.com/multiformats/go-multihash v0.0.1/go.mod h1:w/5tugSrLEbWqlcgJabL3oHFKTwfvkofsjW2Qa1ct4U= github.com/multiformats/go-multihash v0.0.8/go.mod h1:YSLudS+Pi8NHE7o6tb3D8vrpKa63epEDmG8nTduyAew= github.com/multiformats/go-multihash v0.0.10/go.mod h1:YSLudS+Pi8NHE7o6tb3D8vrpKa63epEDmG8nTduyAew= github.com/multiformats/go-multihash v0.0.13/go.mod h1:VdAWLKTwram9oKAatUcLxBNUjdtcVwxObEQBtRfuyjc= -github.com/multiformats/go-multihash v0.0.14/go.mod h1:VdAWLKTwram9oKAatUcLxBNUjdtcVwxObEQBtRfuyjc= -github.com/multiformats/go-multihash v0.0.15/go.mod h1:D6aZrWNLFTV/ynMpKsNtB40mJzmCl4jb1alC0OvHiHg= -github.com/multiformats/go-multihash v0.2.1 h1:aem8ZT0VA2nCHHk7bPJ1BjUbHNciqZC/d16Vve9l108= -github.com/multiformats/go-multihash v0.2.1/go.mod h1:WxoMcYG85AZVQUyRyo9s4wULvW5qrI9vb2Lt6evduFc= github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= github.com/multiformats/go-multistream v0.4.1 h1:rFy0Iiyn3YT0asivDUIR05leAdwZq3de4741sbiSdfo= github.com/multiformats/go-multistream v0.4.1/go.mod h1:Mz5eykRVAjJWckE2U78c6xqdtyNUEhKSM0Lwar2p77Q= github.com/multiformats/go-varint v0.0.1/go.mod h1:3Ls8CIEsrijN6+B7PbrXRPxHRPuXSrVKRY101jdMZYE= github.com/multiformats/go-varint v0.0.5/go.mod h1:3Ls8CIEsrijN6+B7PbrXRPxHRPuXSrVKRY101jdMZYE= -github.com/multiformats/go-varint v0.0.6/go.mod h1:3Ls8CIEsrijN6+B7PbrXRPxHRPuXSrVKRY101jdMZYE= github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8= github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU= -github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= -github.com/onsi/ginkgo/v2 v2.9.2 h1:BA2GMJOtfGAfagzYtrAlufIP0lq6QERkFmHLMLPwFSU= -github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= +github.com/onsi/ginkgo/v2 v2.9.7 h1:06xGQy5www2oN160RtEZoTvnP2sPhEfePYmCDc2szss= github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= -github.com/orangecms/go-framebuffer v0.0.0-20200613202404-a0700d90c330/go.mod h1:3Myb/UszJY32F2G7yGkUtcW/ejHpjlGfYLim7cv2uKA= -github.com/pborman/getopt/v2 v2.1.0/go.mod h1:4NtW75ny4eBw9fO1bhtNdYTlZKYX5/tBLtsOpwKIKd0= -github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= -github.com/pierrec/lz4/v4 v4.1.11/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= -github.com/pierrec/lz4/v4 v4.1.12/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= -github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0= +github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/polydawn/refmt v0.0.0-20190221155625-df39d6c2d992/go.mod h1:uIp+gprXxxrWSjjklXD+mN4wed/tMfjMMmN/9+JsA9o= -github.com/polydawn/refmt v0.0.0-20190807091052-3d65705ee9f1/go.mod h1:uIp+gprXxxrWSjjklXD+mN4wed/tMfjMMmN/9+JsA9o= github.com/polydawn/refmt v0.89.0 h1:ADJTApkvkeBZsN0tBTx8QjpD9JkmxbKp0cxfr9qszm4= github.com/polydawn/refmt v0.89.0/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw= -github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= -github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= github.com/prometheus/client_golang v1.14.0 h1:nJdhIvne2eSX/XRAFV9PcvFFRbrjbcTUj0VP62TMhnw= -github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= -github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4= -github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/client_model v0.4.0 h1:5lQXD3cAg1OXBf4Wq03gTrXHeaV0TQvGfUooCfx1yqY= github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI1YM= -github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= -github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI= github.com/quic-go/qpack v0.4.0 h1:Cr9BXA1sQS2SmDUWjSofMPNKmvF6IiIfDRmgU0w1ZCo= github.com/quic-go/qtls-go1-19 v0.3.2 h1:tFxjCFcTQzK+oMxG6Zcvp4Dq8dx4yD3dDiIiyc86Z5U= github.com/quic-go/qtls-go1-20 v0.2.2 h1:WLOPx6OY/hxtTxKV1Zrq20FtXtDEkeY00CGQm8GEa3E= github.com/quic-go/quic-go v0.33.0 h1:ItNoTDN/Fm/zBlq769lLJc8ECe9gYaW40veHCCco7y0= -github.com/quic-go/webtransport-go v0.5.2 h1:GA6Bl6oZY+g/flt00Pnu0XtivSD8vukOu3lYhJjnGEk= -github.com/rck/unit v0.0.3/go.mod h1:jTOnzP4s1OjIP1vdxb4n76b23QPKS4EurYg7sYMr2DM= -github.com/rekby/gpt v0.0.0-20200219180433-a930afbc6edc/go.mod h1:scrOqOnnHVKCHENvFw8k9ajCb88uqLQDA4BvuJNJ2ew= +github.com/quic-go/webtransport-go v0.5.3 h1:5XMlzemqB4qmOlgIus5zB45AcZ2kCgCy2EptUrfOPWU= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik= github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= -github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/safchain/ethtool v0.0.0-20200218184317-f459e2d13664/go.mod h1:Z0q5wiBQGYcxhMZ6gUqHn6pYNLypFAvaL3UvgZLR0U4= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= -github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs= github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= github.com/smartystreets/goconvey v0.0.0-20190222223459-a17d461953aa/go.mod h1:2RVY1rIf+2J2o/IM9+vPq9RzmHDSseB7FoXiSNIUsoU= -github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/smartystreets/goconvey v1.7.2 h1:9RBaZCeXEQ3UselpuwUQHltGVXvdwm6cv1hgR6gDIPg= github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM= github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= -github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= -github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= -github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= -github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= -github.com/twitchtv/twirp v5.8.0+incompatible/go.mod h1:RRJoFSAmTEh2weEqWtpPE3vFK5YBhA6bqp2l1kfCC5A= -github.com/u-root/iscsinl v0.1.1-0.20210528121423-84c32645822a/go.mod h1:RWIgJWqm9/0gjBZ0Hl8iR6MVGzZ+yAda2uqqLmetE2I= -github.com/u-root/u-root v0.8.0/go.mod h1:But1FHzS4Ua4ywx6kZOaRzZTucUKIDKOPOLEKOckQ68= -github.com/u-root/uio v0.0.0-20210528114334-82958018845c/go.mod h1:LpEX5FO/cB+WF4TYGY1V5qktpaZLkKkSegbr0V4eYXA= -github.com/u-root/uio v0.0.0-20220204230159-dac05f7d2cb4 h1:hl6sK6aFgTLISijk6xIzeqnPzQcsLqqvL6vEfTPinME= -github.com/u-root/uio v0.0.0-20220204230159-dac05f7d2cb4/go.mod h1:LpEX5FO/cB+WF4TYGY1V5qktpaZLkKkSegbr0V4eYXA= -github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= -github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/u-root/uio v0.0.0-20230305220412-3e8cd9d6bf63 h1:YcojQL98T/OO+rybuzn2+5KrD5dBwXIvYBvQ2cD3Avg= +github.com/u-root/uio v0.0.0-20230305220412-3e8cd9d6bf63/go.mod h1:eLL9Nub3yfAho7qB0MzZizFhTU2QkLeoVsWdHtDW264= github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= -github.com/vishvananda/netlink v1.1.0/go.mod h1:cTgwzPIzzgDAYoQrMm0EdrjRUBkTqKYppBueQtXaqoE= -github.com/vishvananda/netlink v1.1.1-0.20211118161826-650dca95af54/go.mod h1:twkDnbuQxJYemMlGd4JFIcuhgX83tXhKS2B/PRMpOho= -github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df/go.mod h1:JP3t17pCcGlemwknint6hfoeCVQrEMVwxRLRjXpq+BU= -github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= -github.com/vishvananda/netns v0.0.0-20210104183010-2eb08e3e575f/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= -github.com/vtolstov/go-ioctl v0.0.0-20151206205506-6be9cced4810/go.mod h1:dF0BBJ2YrV1+2eAIyEI+KeSidgA6HqoIP1u5XTlMq/o= github.com/warpfork/go-testmark v0.11.0 h1:J6LnV8KpceDvo7spaNU4+DauH2n1x+6RaO2rJrmpQ9U= github.com/warpfork/go-wish v0.0.0-20180510122957-5ad1f5abf436/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= -github.com/warpfork/go-wish v0.0.0-20200122115046-b9ea61034e4a/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ= github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= github.com/whyrusleeping/cbor-gen v0.0.0-20200123233031-1cdf64d27158/go.mod h1:Xj/M2wWU+QdTdRbu/L/1dIZY8/Wb2K9pAhtroQuxJJI= @@ -406,26 +276,21 @@ github.com/whyrusleeping/cbor-gen v0.0.0-20230126041949-52956bd4c9aa/go.mod h1:f github.com/whyrusleeping/chunker v0.0.0-20181014151217-fe64bd25879f h1:jQa4QT2UP9WYv2nzyawpKMOCl+Z/jW7djv2/J50lj9E= github.com/winfsp/cgofuse v1.5.1-0.20230130140708-f87f5db493b5 h1:jxZvjx8Ve5sOXorZG0KzTxbp0Cr1n3FEegfmyd9br1k= github.com/winfsp/cgofuse v1.5.1-0.20230130140708-f87f5db493b5/go.mod h1:uxjoF2jEYT3+x+vC2KJddEGdk/LU8pRowXmyVMHSV5I= -github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ= -go.opentelemetry.io/otel v1.14.0 h1:/79Huy8wbf5DnIPhemGB+zEPVwnN6fuQybr/SRXa6hM= -go.opentelemetry.io/otel v1.14.0/go.mod h1:o4buv+dJzx8rohcUeRmWUZhqupFvzWis188WlggnNeU= +github.com/yuin/goldmark v1.5.2 h1:ALmeCk/px5FSm1MAcFBAsVKZjDuMVj8Tm7FFIlMJnqU= +github.com/yuin/goldmark v1.5.2/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark-emoji v1.0.1 h1:ctuWEyzGBwiucEqxzwe0SOYDXPAucOrE9NQC18Wa1os= +github.com/yuin/goldmark-emoji v1.0.1/go.mod h1:2w1E6FEWLcDQkoTE+7HU6QF1F6SLlNGjRIBbIZQFqkQ= go.opentelemetry.io/otel v1.16.0 h1:Z7GVAX/UkAXPKsy94IU+i6thsQS4nb7LviLpnaNeW8s= go.opentelemetry.io/otel v1.16.0/go.mod h1:vl0h9NUa1D5s1nv3A5vZOYWn8av4K8Ml6JDeHrT/bx4= go.opentelemetry.io/otel/metric v1.16.0 h1:RbrpwVG1Hfv85LgnZ7+txXioPDoh6EdbZHo26Q3hqOo= go.opentelemetry.io/otel/metric v1.16.0/go.mod h1:QE47cpOmkwipPiefDwo2wDzwJrlfxxNYodqc4xnGCo4= -go.opentelemetry.io/otel/trace v1.14.0 h1:wp2Mmvj41tDsyAJXiWDWpfNsOiIyd38fy85pyKcFq/M= -go.opentelemetry.io/otel/trace v1.14.0/go.mod h1:8avnQLK+CG77yNLUae4ea2JDQ6iT+gozhnZjy/rw9G8= go.opentelemetry.io/otel/trace v1.16.0 h1:8JRpaObFoW0pxuVPapkgH8UhHQj+bJW8jJsCZEu5MQs= go.opentelemetry.io/otel/trace v1.16.0/go.mod h1:Yt9vYq1SdNz3xdjZZK7wcXv1qv2pwLkqr2QVwea0ef0= go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ= -go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= @@ -439,134 +304,78 @@ go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ= go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI= go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60= go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= -golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190211182817-74369b46fc67/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= -golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A= -golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.10.0 h1:LKqV2xt9+kDzSTfOhx4FrkEBcMrAgHSYgzywV9zcGmM= golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20230321023759-10a507213a29 h1:ooxPy7fPvB4kwsA2h+iBNHkAbp/4JxTSwCmvdjEYmug= golang.org/x/exp v0.0.0-20230321023759-10a507213a29/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk= golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190419010253-1f3472d942ba/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20191007182048-72f939374954/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= -golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= +golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI= golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190219092855-153ac476189d/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190411185658-b44545bcd369/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190418153312-f0ce4c0180be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190606122018-79a91cf218c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190606203320-7fc4e5ec1444/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200121082415-34d275377bf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201101102859-da207088b7d1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210309074719-68d13333faf2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210525143221-35b2ab0089ea/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210820121016-41cdb8703e55/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU= -golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s= golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210317153231-de623e64d2a6/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.9.0 h1:GRRCnKYhdQrD8kfRAdQ6Zcw1P0OcELxGLKJvtjVMZ28= +golang.org/x/term v0.9.0/go.mod h1:M6DEAAIenWoTxdKrOltXcmDY3rSplQUkrvaDU5FcQyo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68= +golang.org/x/text v0.10.0 h1:UpjohKhiEgNc0CSauXmwYftY1+LlaC75SJwh0SgCX58= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190614205625-5aca471b1d59/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.9/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= -golang.org/x/tools v0.7.0 h1:W4OVu8VVOaIO0yzWMNdepAulS7YfoS3Zabrm8DOXXU4= -golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s= +golang.org/x/tools v0.9.1 h1:8WMNJAz3zrtPmnYC7ISf5dEn3MT0gY7jBJfw27yrrLo= golang.org/x/tools v0.9.1/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -574,37 +383,21 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= -google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= -google.golang.org/grpc v1.53.0 h1:LAv2ds7cmFV/XTS3XG1NneeENYrXGmorPxsBbptIjNc= +google.golang.org/grpc v1.55.0 h1:3Oj82/tFSCeUrRTg/5E/7d/W5A1tj6Ky1ABAuZuv5ag= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= -gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= -lukechampine.com/blake3 v1.1.7 h1:GgRMhmdsuK8+ii6UZFDL8Nb+VyMwadAgcJyfYHxG6n0= -lukechampine.com/blake3 v1.1.7/go.mod h1:tkKEOtDkNtklkXtLNEOGNq5tcV90tJiA1vAA12R78LA= lukechampine.com/blake3 v1.2.1 h1:YuqqRuaqsGV71BV/nm9xlI0MKUv4QC54jQnBChWbGnI= lukechampine.com/blake3 v1.2.1/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k= -pack.ag/tftp v1.0.1-0.20181129014014-07909dfbde3c/go.mod h1:N1Pyo5YG+K90XHoR2vfLPhpRuE8ziqbgMn/r/SghZas= -src.elv.sh v0.16.3/go.mod h1:WxJAMoN8uQcg1ZwRvtjmbYAo6uKeJ8F7b25etVZ743w= diff --git a/internal/command/command.go b/internal/command/command.go index eb0c229b..0457eb82 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -4,184 +4,499 @@ import ( "context" "errors" "flag" + "fmt" + "io" "os" + "reflect" "strings" + "text/tabwriter" + + "github.com/charmbracelet/glamour" + "github.com/charmbracelet/glamour/ansi" + "github.com/djdv/go-filesystem-utils/internal/generic" + "golang.org/x/term" ) -// TODO: name and docs type ( - // Settings is a constraint that permits any reference type - // which also implements a [FlagBinder] and the help flag hook method. - Settings[T any] interface { + // Command is a decorated function ready to be executed. + Command interface { + // Name returns a human friendly name of the command, + // which may be used to identify commands + // as well as decorate user facing help-text. + Name() string + + // Synopsis returns a single-line short string describing the command. + Synopsis() string + + // Usage returns an arbitrarily long string explaining how to use the command. + Usage() string + + // Subcommands returns a list of subcommands (if any). + Subcommands() []Command + + // Execute executes the command, with or without any arguments. + Execute(ctx context.Context, args ...string) error + } + // ExecuteType is a constraint that permits any reference + // type that can bind its value(s) to flags. + ExecuteType[T any] interface { *T - HelpFlag FlagBinder } + // A FlagBinder should call relevant [flag.FlagSet] methods + // to bind each of it's variable references with the FlagSet. + // E.g. a struct would pass references of its fields + // to `FlagSet.Var(&structABC.fieldXYZ, ...)`. + FlagBinder interface { + BindFlags(*flag.FlagSet) + } + // ValueNamer may be implemented by a [flag.Value] + // to specify the name of its parameter type, but + // is only used if the name is absent in the usage string. + ValueNamer interface { + Name() string + } + // Option is a functional option. + // One can be returned by the various constructors + // before being passed to [MakeCommand]. + Option func(*commandCommon) + commandCommon struct { + name, synopsis, usage string + usageOutput io.Writer + subcommands []Command + glamour bool + } + + // UsageError may be returned by commands + // to signal that its usage string should + // be presented to the caller. + UsageError struct{ Err error } + + writeStringFunc func(string) + stringModiferFunc func(string) string +) + +func (ue UsageError) Error() string { return ue.Err.Error() } + +// Unwrap implements the [errors.Unwrap] interface. +func (ue UsageError) Unwrap() error { return ue.Err } - // TODO: docs - ExecuteFunc[settings Settings[T], T any] interface { - func(context.Context, settings) error +func unexpectedArguments(name string, args []string) UsageError { + return UsageError{ + Err: fmt.Errorf( + "`%s` does not take arguments but was provided: %s", + name, strings.Join(args, ","), + ), } +} - // TODO: docs - // The primary expected signature of a command's Execute function/method. - ExecuteFuncArgs[settings Settings[T], T any] interface { - func(context.Context, settings, ...string) error +// WithSubcommands provides a command with subcommands. +// Subcommands will be called if the supercommand receives +// arguments that match the subcommand name. +func WithSubcommands(subcommands ...Command) Option { + return func(settings *commandCommon) { + settings.subcommands = subcommands } +} - CommandFunc[settings Settings[T], T any] interface { - ExecuteFunc[settings, T] | ExecuteFuncArgs[settings, T] +// WithUsageOutput sets the writer that is written +// to when [Command.Execute] receives a request for +// help, or returns [UsageError]. +func WithUsageOutput(output io.Writer) Option { + return func(settings *commandCommon) { + settings.usageOutput = output } +} - // TODO: docs - // interface level signature of [CommandFunc]. - commandFunc func(context.Context, ...string) error - usageFunc func(StringWriter, *flag.FlagSet) error +// SubcommandGroup returns a command that only defers to subcommands. +// Trying to execute the command itself will return [UsageError]. +func SubcommandGroup(name, synopsis string, subcommands []Command, options ...Option) Command { + const usage = "Must be called with a subcommand." + return MakeNiladicCommand(name, synopsis, usage, + func(context.Context) error { + return UsageError{ + Err: fmt.Errorf( + "`%s` only accepts subcommands", name, + ), + } + }, + append(options, WithSubcommands(subcommands...))..., + ) +} - command struct { - name, synopsis string - usage usageFunc - execute commandFunc - subcommands []Command - } -) +func (cmd *commandCommon) Name() string { return cmd.name } +func (cmd *commandCommon) Synopsis() string { return cmd.synopsis } +func (cmd *commandCommon) Usage() string { return cmd.usage } +func (cmd *commandCommon) Subcommands() []Command { return generic.CloneSlice(cmd.subcommands) } -func MakeCommand[settings Settings[T], - T any, - execFunc CommandFunc[settings, T], -]( - name, synopsis, usage string, - exec execFunc, options ...Option, -) Command { - constructorSettings, err := parseOptions(options...) - if err != nil { - panic(err) +func newFlagSet(name string) *flag.FlagSet { + return flag.NewFlagSet(name, flag.ContinueOnError) +} + +func (cmd *commandCommon) parseFlags(flagSet *flag.FlagSet, arguments ...string) (bool, error) { + var needHelp bool + bindHelpFlag(&needHelp, flagSet) + bindRenderFlag(&cmd.glamour, flagSet) + // Package [flag] has implicit handling for `-help` and `-h` flags. + // If they're not explicitly defined, but provided as arguments, + // [flag] will call `Usage` before returning from `Parse`. + // We want to disable any built-in printing, to assure + // our printers are used exclusively. (For both help text and errors) + flagSet.Usage = func() { /* NOOP */ } + flagSet.SetOutput(io.Discard) + err := flagSet.Parse(arguments) + if err == nil { + return needHelp, nil } - cmd := &command{ - name: name, - synopsis: synopsis, - subcommands: constructorSettings.subcommands, + if errors.Is(err, flag.ErrHelp) { + needHelp = true + return needHelp, nil } - cmd.usage = wrapUsage[settings](cmd, usage) - cmd.execute = wrapExecute[settings](constructorSettings.usageOutput, cmd, exec) - return cmd + return needHelp, UsageError{Err: err} } -func (cmd *command) Name() string { return cmd.name } -func (cmd *command) Usage() string { - output := new(strings.Builder) - if err := cmd.usage(output, nil); err != nil { - panic(err) +func bindHelpFlag(value *bool, flagSet *flag.FlagSet) { + const ( + helpName = "help" + helpUsage = "prints out this help text" + helpDefault = false + ) + flagSet.BoolVar(value, helpName, helpDefault, helpUsage) +} + +func bindRenderFlag(value *bool, flagSet *flag.FlagSet) { + const ( + renderName = "video-terminal" + renderUsage = "render text for video terminals" + renderDefault = true + ) + flagSet.BoolVar(value, renderName, renderDefault, renderUsage) +} + +func getSubcommand(command Command, arguments []string) (Command, []string) { + if len(arguments) == 0 { + return nil, nil } - return output.String() + subname := arguments[0] + for _, subcommand := range command.Subcommands() { + if subcommand.Name() != subname { + continue + } + subarguments := arguments[1:] + if hypoCmd, hypoArgs := getSubcommand(subcommand, subarguments); hypoCmd != nil { + return hypoCmd, hypoArgs + } + return subcommand, subarguments + } + return nil, nil } -func (cmd *command) Synopsis() string { return cmd.synopsis } -func (cmd *command) Subcommands() []Command { return cmd.subcommands } -func (cmd *command) Execute(ctx context.Context, args ...string) error { - return cmd.execute(ctx, args...) + +func (cmd *commandCommon) maybePrintUsage(err error, acceptsArgs bool, flagSet *flag.FlagSet) error { + var usageErr UsageError + if !errors.Is(err, flag.ErrHelp) && + !errors.As(err, &usageErr) { + return err + } + if printErr := cmd.printUsage(acceptsArgs, flagSet); printErr != nil { + return printErr + } + return err } -func wrapUsage[settings Settings[T], T any](cmd *command, - usage string, -) func(StringWriter, *flag.FlagSet) error { +func (cmd *commandCommon) printUsage(acceptsArgs bool, flagSet *flag.FlagSet) error { var ( + output = cmd.usageOutput + wantStyled = cmd.glamour + renderer *glamour.TermRenderer + ) + if output == nil { + output, wantStyled = newDefaultOutput(wantStyled) + } + if wantStyled { + var err error + if renderer, err = newRenderer(); err != nil { + return err + } + } + var ( + wErr error + writeFn = func(text string) { + if wErr != nil { + return + } + _, wErr = io.WriteString(output, text) + } name = cmd.name + usage = cmd.usage subcommands = cmd.subcommands + hasSubs = len(subcommands) > 0 + hasFlags bool + ) + flagSet.VisitAll(func(*flag.Flag) { hasFlags = true }) + printUsage(writeFn, usage, renderer) + printCommandLine(writeFn, name, hasSubs, hasFlags, acceptsArgs, renderer) + if hasFlags { + printFlags(writeFn, flagSet, renderer) + } + if hasSubs { + printSubcommands(writeFn, subcommands, renderer) + } + return wErr +} + +func newDefaultOutput(withStyle bool) (_ io.Writer, supportsANSI bool) { + stderr := os.Stderr + if withStyle { + if term.IsTerminal(int(stderr.Fd())) { + return ansiStderr(), true + } + } + return stderr, false +} + +func mustRender(renderer *glamour.TermRenderer, text string) string { + render, err := renderer.Render(text) + if err != nil { + panic(err) + } + return strings.TrimSpace(render) +} + +func printUsage( + writeFn writeStringFunc, + usage string, renderer *glamour.TermRenderer, +) { + if renderer != nil { + usage = mustRender(renderer, usage) + } + writeFn(usage + "\n\n") +} + +func printCommandLine( + writeFn writeStringFunc, + name string, + hasSubcommands, hasFlags, acceptsArgs bool, + renderer *glamour.TermRenderer, +) { + var ( + usageText string + styled = renderer != nil + render stringModiferFunc ) - return func(output StringWriter, flagSet *flag.FlagSet) error { - if output == nil { - output = os.Stderr + if styled { + render = func(text string) string { + return mustRender(renderer, text) } - if flagSet == nil { - flagSet = flag.NewFlagSet(name, flag.ContinueOnError) - (settings)(new(T)).BindFlags(flagSet) + usageText = render("Usage:") + + "\n\t" + render(bold(name)) + } else { + usageText = "Usage:\n\t" + name + } + writeFn(usageText) + if hasSubcommands { + subcommandText := "subcommand" + if styled { + subcommandText = render(subcommandText) } - return printHelpText(output, name, usage, flagSet, subcommands...) + writeFn(" " + subcommandText) } + if hasFlags { + var flagsText string + if styled { + // NOTE: this could be done in a single pass + // but "**[**flags***]**" confuses glamour, + // and using underscores is discouraged. + flagsText = render(bold("[")) + + render(italic("flags")) + + render(bold("]")) + } else { + flagsText = "[flags]" + } + writeFn(" " + flagsText) + } + if acceptsArgs { + argumentsText := "...arguments" + if styled { + argumentsText = render(italic(argumentsText)) + } + writeFn(" " + argumentsText) + } + writeFn("\n") } -// wrapExecute -// - parses arguments -// - checks [command.HelpFlag] -// - checks argc against func arity. -// - may call [command.CommandFunc] -// - may print [command.Usage] -func wrapExecute[settings Settings[T], T any, - execFunc CommandFunc[settings, T], -](usageOutput StringWriter, cmd *command, execFn execFunc, -) func(context.Context, ...string) error { - return func(ctx context.Context, args ...string) error { - var ( - flagSet, set, err = parseArgs[settings](cmd, args...) - maybePrintUsage = func(err error) error { - if errors.Is(err, ErrUsage) { - if printErr := cmd.usage(usageOutput, flagSet); printErr != nil { - return printErr - } - } - return err - } - ) - if err != nil { - return maybePrintUsage(err) +// *modification of standard [flag]'s implementation. +func printFlags( + writeFn writeStringFunc, + flagSet *flag.FlagSet, + renderer *glamour.TermRenderer, +) { + var ( + flagText = "Flags:" + styled = renderer != nil + render, italicUnderline stringModiferFunc + ) + if styled { + render = func(text string) string { + return mustRender(renderer, text) } + italicUnderline = newItalicUnderlineRenderer(renderer) + flagText = render("Flags:") + } + writeFn(flagText + "\n") + flagSet.VisitAll(func(flg *flag.Flag) { + const singleCharName = 2 var ( - subcommands = cmd.subcommands - haveSubs = len(subcommands) > 0 - arguments = flagSet.Args() - haveArgs = len(arguments) > 0 + flagName = "-" + flg.Name + shortFlag = len(flagName) == singleCharName ) - if haveSubs && haveArgs { - if ran, err := execSub(ctx, subcommands, arguments); ran { - return err + if styled { + flagName = render(bold(flagName)) + } + writeFn(" " + flagName) + valueType, usage := unquoteUsage(flg) + if len(valueType) > 0 { + if styled { + valueType = italicUnderline(valueType) } + writeFn(" " + valueType) + } + if shortFlag { + writeFn("\t") + } else { + writeFn("\n \t") + } + if styled { + usage = render(usage) } - var execErr error - switch execFn := any(execFn).(type) { - case func(context.Context, settings) error: - if haveArgs { - execErr = ErrUsage + writeFn(strings.ReplaceAll(usage, "\n", "\n \t")) + if defaultText := flg.DefValue; !isZeroValue(flg, defaultText) { + const prefix, suffix = "(default: ", ")" + if styled { + if !strings.Contains(defaultText, "`") { + defaultText = "`" + defaultText + "`" + } + defaultText = render(prefix + defaultText + suffix) } else { - execErr = execFn(ctx, set) + defaultText = prefix + defaultText + suffix } - case func(context.Context, settings, ...string) error: - execErr = execFn(ctx, set, arguments...) + writeFn("\n \t" + defaultText) } - return maybePrintUsage(execErr) + writeFn("\n") + }) +} + +// HACK: Markdown doesn't have syntax for underline, +// but we can render it manually since ANSI supports it. +func newItalicUnderlineRenderer(renderer *glamour.TermRenderer) stringModiferFunc { + var ( + style = _extractStyle(renderer) + ctx = ansi.NewRenderContext(ansi.Options{}) + verum = true + color *string + ) + if textColor := style.Text.Color; textColor != nil { + color = textColor + } else if documentColor := style.Document.Color; documentColor != nil { + color = documentColor + } + var ( + builder strings.Builder + element = &ansi.BaseElement{ + Style: ansi.StylePrimitive{ + Color: color, + Italic: &verum, + Underline: &verum, + }, + } + ) + return func(text string) string { + element.Token = text + if err := element.Render(&builder, ctx); err != nil { + panic(err) + } + render := builder.String() + builder.Reset() + return render } } -func parseArgs[settings Settings[T], T any](cmd *command, args ...string, -) (*flag.FlagSet, settings, error) { - flagSet, set, err := parseFlags[settings](cmd.name, args...) - if err != nil { - return nil, nil, err +func unquoteUsage(flg *flag.Flag) (name, usage string) { + name, usage = flag.UnquoteUsage(flg) + if name == "value" { + if namer, ok := flg.Value.(ValueNamer); ok { + name = namer.Name() + } } - if set.Help() { - return flagSet, set, ErrUsage + return name, usage +} + +// isZeroValue determines whether the string represents the zero +// value for a flag. +// *Borrowed from standard library. +func isZeroValue(flg *flag.Flag, value string) bool { + // Build a zero value of the flag's Value type, and see if the + // result of calling its String method equals the value passed in. + // This works unless the Value type is itself an interface type. + typ := reflect.TypeOf(flg.Value) + var zero reflect.Value + if typ.Kind() == reflect.Pointer { + zero = reflect.New(typ.Elem()) + } else { + zero = reflect.Zero(typ) } - return flagSet, set, nil + // Deviation from standard; if the code is wrong + // let this panic. Package [flag] explicitly requires + // [flag.Value] to have a `String` method that is + // callable with a `nil` receiver. + return value == zero.Interface().(flag.Value).String() } -func parseFlags[settings Settings[T], T any](name string, args ...string, -) (*flag.FlagSet, settings, error) { +func printSubcommands(writeFn writeStringFunc, subcommands []Command, renderer *glamour.TermRenderer) { var ( - flagSet = flag.NewFlagSet(name, flag.ContinueOnError) - set settings = new(T) + subcommandsText = "Subcommands:" + styled = renderer != nil + render stringModiferFunc ) - set.BindFlags(flagSet) - if err := flagSet.Parse(args); err != nil { - return nil, nil, err + if styled { + render = func(text string) string { + return mustRender(renderer, text) + } + subcommandsText = render(subcommandsText) } - return flagSet, set, nil + writeFn(subcommandsText + "\n") + const ( + minWidth = 0 + tabWidth = 0 + padding = 0 + padChar = ' ' + flags = 0 + ) + var ( + subcommandsBuffer strings.Builder + tabWriter = tabwriter.NewWriter( + &subcommandsBuffer, minWidth, tabWidth, padding, padChar, flags, + ) + ) + for _, subcommand := range subcommands { + if _, err := fmt.Fprintf(tabWriter, + " %s\t - %s\n", // 2 leading spaces to match [flag] behaviour. + subcommand.Name(), subcommand.Synopsis(), + ); err != nil { + panic(err) + } + } + if err := tabWriter.Flush(); err != nil { + panic(err) + } + subcommandsTable := subcommandsBuffer.String() + if styled { + subcommandsTable = render(subcommandsTable) + } + writeFn(subcommandsTable + "\n") } -func execSub(ctx context.Context, subcommands []Command, arguments []string) (bool, error) { - subname := arguments[0] - for _, subcmd := range subcommands { - if subcmd.Name() == subname { - return true, subcmd.Execute(ctx, arguments[1:]...) - } +func applyOptions(settings *commandCommon, options ...Option) { + for _, apply := range options { + apply(settings) } - return false, nil } diff --git a/internal/command/command_test.go b/internal/command/command_test.go index 0c3fb901..b6f8c884 100644 --- a/internal/command/command_test.go +++ b/internal/command/command_test.go @@ -4,204 +4,632 @@ import ( "context" "errors" "flag" - "fmt" "io" + "os" + "reflect" "testing" "github.com/djdv/go-filesystem-utils/internal/command" ) -const ( - synopisSuffix = " Synopis" - usageSuffix = " Usage" - - noopName = "noop" - noopArgsName = "noopArgs" -) +func TestCommand(t *testing.T) { + t.Parallel() + t.Run("niladic", cmdNiladic) + t.Run("fixed", cmdFixed) + t.Run("variadic", cmdVariadic) + t.Run("subcommands", cmdSubcommands) + t.Run("renderer", rendererTest) +} -type ( - settings struct { - command.HelpArg - someField bool +func testHelpText(t *testing.T, cmd command.Command) { + t.Helper() + name := cmd.Name() + if len(name) == 0 { + t.Errorf( + "command did not identify itself by name: %T", + cmd, + ) } - - cmdMap map[string]command.Command -) - -func noopCmds() cmdMap { - const ( - noopSynopsis = noopName + synopisSuffix - noopUsage = noopName + usageSuffix - - noopArgsSynopsis = noopArgsName + synopisSuffix - noopArgsUsage = noopArgsName + usageSuffix - ) - return cmdMap{ - noopName: command.MakeCommand[*settings]( - noopName, noopSynopsis, noopUsage, noop, - ), - noopArgsName: command.MakeCommand[*settings]( - noopArgsName, noopArgsSynopsis, noopArgsUsage, noopArgs, - ), + synopsis := cmd.Synopsis() + if len(synopsis) == 0 { + t.Errorf( + `command "%s" did not return a synopsis`, + name, + ) + } + usage := cmd.Usage() + if len(usage) == 0 { + t.Errorf( + `command "%s" did not return usage help text`, + name, + ) } } -func (ts *settings) BindFlags(fs *flag.FlagSet) { - ts.HelpArg.BindFlags(fs) - fs.BoolVar(&ts.someField, "sf", false, "Some Flag") -} - -func noop(ctx context.Context, set *settings) error { - return nil +func testErrorParameters(t *testing.T, cmd command.Command) { + t.Helper() + const usageMessage = "expected `UsageError`" + ctx := context.Background() + for _, test := range []struct { + arguments []string + message string + }{ + { + arguments: []string{"-help"}, + message: usageMessage, + }, + { + arguments: []string{"-h"}, + message: usageMessage, + }, + { + arguments: []string{"-invalid"}, + message: usageMessage, + }, + { + arguments: []string{"some", "arguments"}, + message: usageMessage, + }, + } { + if err := cmd.Execute(ctx, test.arguments...); err == nil { + t.Error(test.message) + } + } } -func noopArgs(ctx context.Context, set *settings, args ...string) error { - return nil +func cmdNiladic(t *testing.T) { + t.Parallel() + t.Run("help text", nilCmd) + t.Run("valid", nilValid) + t.Run("invalid", nilInvalid) } -func TestCommand(t *testing.T) { +func nilCmd(t *testing.T) { t.Parallel() - t.Run("MakeCommand", cmdMake) - t.Run("Execute", cmdExecute) + cmd := newNiladicTestCommand(t) + testHelpText(t, cmd) } -func cmdMake(t *testing.T) { +func newNiladicTestCommand(t *testing.T) command.Command { + t.Helper() const ( - name = "subcommands" - synopsis = name + synopisSuffix - usage = name + usageSuffix + name = "niladic" + synopsis = "Prints a message." + usage = "Call the command with no arguments" ) - var ( - noopCmds = noopCmds() - execFn = noop - cmd = command.MakeCommand[*settings]( - name, synopsis, usage, execFn, - command.WithSubcommands(noopCmds[noopName]), - command.WithSubcommands(noopCmds[noopArgsName]), - ) + output := io.Discard + return command.MakeNiladicCommand( + name, synopsis, usage, + func(context.Context) error { return nil }, + command.WithUsageOutput(output), ) - if usage := cmd.Usage(); usage == "" { - t.Errorf("usage string for command \"%s\", is empty", noopName) - } } -func cmdExecute(t *testing.T) { +func nilValid(t *testing.T) { t.Parallel() - t.Run("valid", exeValid) - t.Run("invalid", exeInvalid) + var ( + cmd = newNiladicTestCommand(t) + ctx = context.Background() + ) + if err := cmd.Execute(ctx); err != nil { + t.Error(err) + } } -func exeValid(t *testing.T) { +func nilInvalid(t *testing.T) { t.Parallel() - const ( - subsName = "subcommands" - subsSynopsis = subsName + synopisSuffix - subsUsage = subsName + usageSuffix - - subName = "subcommand" - subSynopsis = subName + synopisSuffix - subUsage = subName + usageSuffix - ) - cmds := noopCmds() - cmds[subsName] = command.MakeCommand[*settings]( - subsName, subsSynopsis, subsUsage, noop, - command.WithSubcommands( - command.MakeCommand[*settings]( - subName, subSynopsis, subUsage, noop, - ), - ), + var ( + cmd = newNiladicTestCommand(t) + ctx = context.Background() ) + const usageMessage = "expected UsageError" for _, test := range []struct { - name string - args []string + arguments []string + message string }{ { - noopName, - nil, + arguments: []string{"-help"}, + message: usageMessage, }, { - noopArgsName, - []string{"arg1", "arg2"}, + arguments: []string{"-h"}, + message: usageMessage, }, { - subsName, - []string{subName}, + arguments: []string{"-invalid"}, + message: usageMessage, }, { - noopName, - []string{"-sf=true"}, + arguments: []string{"some", "arguments"}, + message: usageMessage, }, } { - var ( - name = test.name - args = test.args - ) - t.Run(fmt.Sprint(name, args), func(t *testing.T) { - t.Parallel() - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - if err := cmds[name].Execute(ctx, args...); err != nil { - t.Error(err) - } - }) + if err := cmd.Execute(ctx, test.arguments...); err == nil { + t.Error(test.message) + } } } -func exeInvalid(t *testing.T) { +func cmdFixed(t *testing.T) { t.Parallel() + t.Run("help text", fixedCmd) + t.Run("valid", fixedValid) + t.Run("invalid", fixedInvalid) +} + +func fixedCmd(t *testing.T) { + t.Parallel() + var ( + cmd, _ = newFixedTestCommand(t) + cmdArgs, _, _ = newFixedArgsTestCommand(t) + ) + testHelpText(t, cmd) + testHelpText(t, cmdArgs) +} + +func newFixedTestCommand(t *testing.T) (command.Command, *fixedType) { + t.Helper() const ( - name = "testcmd" - synopsis = name + synopisSuffix - usage = name + usageSuffix + name = "fixed" + synopsis = "Prints a value." + usage = "Call the command with or" + + " without flags" + flagDefault = 1 ) var ( - discard = io.Discard.(command.StringWriter) - execFn = noop - cmds = cmdMap{ - name: command.MakeCommand[*settings]( - name, synopsis, usage, execFn, - command.WithUsageOutput(discard), - ), + fixed = &fixedType{ + someField: flagDefault, } + output = io.Discard + cmd = command.MakeFixedCommand[*fixedType]( + name, synopsis, usage, + func(_ context.Context, settings *fixedType) error { + *fixed = *settings + return nil + }, + command.WithUsageOutput(output), + ) ) - for _, test := range []struct { - name string - args []string - expected error - reason string - }{ - { - name, - []string{"arg1", "arg2"}, - command.ErrUsage, - "niladic function called with args", - }, - { - name, - []string{"-help"}, - command.ErrUsage, - "function called with help flag", + return cmd, fixed +} + +func newFixedArgsTestCommand(t *testing.T) (command.Command, *fixedType, *[]string) { + t.Helper() + const ( + name = "fixed" + synopsis = "Prints a value." + usage = "Call the command with or" + + " without flags" + flagDefault = 1 + ) + var ( + fixed = &fixedType{ + someField: flagDefault, + } + args = new([]string) + output = io.Discard + cmd = command.MakeFixedCommand[*fixedType]( + name, synopsis, usage, + func(_ context.Context, settings *fixedType, arguments ...string) error { + *args = arguments + *fixed = *settings + return nil + }, + command.WithUsageOutput(output), + ) + ) + return cmd, fixed, args +} + +func fixedValid(t *testing.T) { + t.Parallel() + t.Run("flags", fixedValidFlags) + t.Run("arguments", fixedValidArguments) +} + +func fixedValidFlags(t *testing.T) { + t.Parallel() + var ( + cmd, settings = newFixedTestCommand(t) + ctx = context.Background() + settingsPre = *settings + ) + if err := cmd.Execute(ctx); err != nil { + t.Error(err) + } + if got := *settings; got != settingsPre { + t.Errorf( + "no arguments provided but settings changed from defaults"+ + "\n\tgot: %#v"+ + "\n\twant: %#v", + got, settingsPre, + ) + } + if err := cmd.Execute(ctx, "-flag=2"); err != nil { + t.Error(err) + } + want := settingsPre + want.someField = 2 + if got := *settings; got != want { + t.Errorf( + "arguments provided but settings did not changed from defaults"+ + "\n\tgot: %#v"+ + "\n\twant: %#v", + *settings, settingsPre, + ) + } +} + +func fixedValidArguments(t *testing.T) { + t.Parallel() + var ( + cmd, settings, arguments = newFixedArgsTestCommand(t) + ctx = context.Background() + settingsPre = *settings + ) + if err := cmd.Execute(ctx); err != nil { + t.Error(err) + } + if got := *settings; got != settingsPre { + t.Errorf( + "no arguments provided but settings changed from defaults"+ + "\n\tgot: %#v"+ + "\n\twant: %#v", + got, settingsPre, + ) + } + if err := cmd.Execute(ctx, "-flag=2"); err != nil { + t.Error(err) + } + want := settingsPre + want.someField = 2 + if got := *settings; got != want { + t.Errorf( + "arguments provided but settings did not changed from defaults"+ + "\n\tgot: %#v"+ + "\n\twant: %#v", + *settings, settingsPre, + ) + } + wantArguments := []string{"a", "b", "c"} + if err := cmd.Execute(ctx, wantArguments...); err != nil { + t.Error(err) + } + if got := *arguments; !reflect.DeepEqual(got, wantArguments) { + t.Errorf( + "arguments provided but vector did not change"+ + "\n\tgot: %#v"+ + "\n\twant: %#v", + got, wantArguments, + ) + } +} + +func fixedInvalid(t *testing.T) { + t.Parallel() + cmd, _ := newFixedTestCommand(t) + testErrorParameters(t, cmd) +} + +func cmdVariadic(t *testing.T) { + t.Parallel() + t.Run("help text", variadicCmd) + t.Run("valid", variadicValid) + t.Run("invalid", variadicInvalid) +} + +func variadicCmd(t *testing.T) { + t.Parallel() + var ( + cmd, _ = newVariadicTestCommand(t) + cmdArgs, _, _ = newVariadicArgsTestCommand(t) + ) + testHelpText(t, cmd) + testHelpText(t, cmdArgs) +} + +func newVariadicTestCommand(t *testing.T) (command.Command, *settings) { + const ( + name = "variadic" + synopsis = "Prints a value." + usage = "Call the command with or" + + " without flags" + ) + var ( + settings = settings{ + someField: variadicFlagDefault, + } + output = io.Discard + cmd = command.MakeVariadicCommand[options]( + name, synopsis, usage, + func(ctx context.Context, options ...option) error { + for _, apply := range options { + if err := apply(&settings); err != nil { + return err + } + } + return nil + }, + command.WithUsageOutput(output), + ) + ) + return cmd, &settings +} + +func newVariadicArgsTestCommand(t *testing.T) (command.Command, *settings, *[]string) { + const ( + name = "fixed-args" + synopsis = "Prints a value and arguments." + usage = "Call the command with or" + + " without flags or arguments" + ) + var ( + args = new([]string) + settings = settings{ + someField: variadicFlagDefault, + } + output = io.Discard + cmd = command.MakeVariadicCommand[options]( + name, synopsis, usage, + func(ctx context.Context, arguments []string, options ...option) error { + for _, apply := range options { + if err := apply(&settings); err != nil { + return err + } + } + *args = arguments + return nil + }, + command.WithUsageOutput(output), + ) + ) + return cmd, &settings, args +} + +func variadicValid(t *testing.T) { + t.Parallel() + t.Run("flags", variadicValidFlags) + t.Run("arguments", variadicValidArguments) +} + +func variadicValidFlags(t *testing.T) { + t.Parallel() + var ( + cmd, settings = newVariadicTestCommand(t) + ctx = context.Background() + settingsPre = *settings + ) + if err := cmd.Execute(ctx); err != nil { + t.Error(err) + } + if got := *settings; got != settingsPre { + t.Errorf( + "no arguments provided but settings changed from defaults"+ + "\n\tgot: %#v"+ + "\n\twant: %#v", + got, settingsPre, + ) + } + if err := cmd.Execute(ctx, "-flag=2"); err != nil { + t.Error(err) + } + want := settingsPre + want.someField = 2 + if got := *settings; got != want { + t.Errorf( + "arguments provided but settings did not changed from defaults"+ + "\n\tgot: %#v"+ + "\n\twant: %#v", + *settings, settingsPre, + ) + } +} + +func variadicValidArguments(t *testing.T) { + t.Parallel() + var ( + cmd, settings, arguments = newVariadicArgsTestCommand(t) + ctx = context.Background() + settingsPre = *settings + ) + if err := cmd.Execute(ctx); err != nil { + t.Error(err) + } + if got := *settings; got != settingsPre { + t.Errorf( + "no arguments provided but settings changed from defaults"+ + "\n\tgot: %#v"+ + "\n\twant: %#v", + got, settingsPre, + ) + } + if err := cmd.Execute(ctx, "-flag=2"); err != nil { + t.Error(err) + } + want := settingsPre + want.someField = 2 + if got := *settings; got != want { + t.Errorf( + "arguments provided but settings did not changed from defaults"+ + "\n\tgot: %#v"+ + "\n\twant: %#v", + *settings, settingsPre, + ) + } + wantArguments := []string{"a", "b", "c"} + if err := cmd.Execute(ctx, wantArguments...); err != nil { + t.Error(err) + } + if got := *arguments; !reflect.DeepEqual(got, wantArguments) { + t.Errorf( + "arguments provided but vector did not change"+ + "\n\tgot: %#v"+ + "\n\twant: %#v", + got, wantArguments, + ) + } +} + +func variadicInvalid(t *testing.T) { + t.Parallel() + cmd, _ := newVariadicTestCommand(t) + testErrorParameters(t, cmd) +} + +func cmdSubcommands(t *testing.T) { + t.Parallel() + t.Run("help text", subcommandCmd) + t.Run("valid", subcommandValid) + t.Run("invalid", subcommandInvalid) +} + +func newTestSubcommands(t *testing.T) command.Command { + var ( + noopFn = func(context.Context) error { return nil } + makeCommand = func(name string) command.Command { + var ( + synopsis = name + " synopsis" + usage = name + " usage" + cmd = command.MakeNiladicCommand( + name, synopsis, usage, noopFn, + ) + ) + return cmd + } + output = io.Discard + cmdOptions = []command.Option{ + command.WithUsageOutput(output), + } + ) + return command.SubcommandGroup( + "top", "Top level group", + []command.Command{ + command.SubcommandGroup( + "A", "middle group 1", + []command.Command{ + makeCommand("1"), + }, + cmdOptions..., + ), + command.SubcommandGroup( + "B", "middle group 2", + []command.Command{ + makeCommand("2"), + }, + cmdOptions..., + ), }, + cmdOptions..., + ) +} + +func subcommandCmd(t *testing.T) { + t.Parallel() + cmd := newTestSubcommands(t) + testHelpText(t, cmd) +} + +func subcommandValid(t *testing.T) { + t.Parallel() + + const ( + niladicName = "niladic" + fixedName = "fixed" + variadicName = "variadic" + synopsis = "" + usage = synopsis + ) + var ( + ctx = context.Background() + output = io.Discard + groupCmd = newTestSubcommands(t) + nilCmd = command.MakeNiladicCommand( + niladicName, synopsis, usage, + func(context.Context) error { return nil }, + command.WithUsageOutput(output), + command.WithSubcommands(groupCmd), + ) + fixedCmd = command.MakeFixedCommand[*fixedType]( + fixedName, synopsis, usage, + func(context.Context, *fixedType) error { return nil }, + command.WithUsageOutput(output), + command.WithSubcommands(groupCmd), + ) + variadicCmd = command.MakeVariadicCommand[options]( + variadicName, synopsis, usage, + func(context.Context, ...option) error { return nil }, + command.WithUsageOutput(output), + command.WithSubcommands(groupCmd), + ) + subnames = [][]string{ + {"A", "1"}, + {"B", "2"}, + } + ) + for i, arguments := range subnames { + if err := groupCmd.Execute(ctx, arguments...); err != nil { + t.Error(err) + } + subnames[i] = append([]string{groupCmd.Name()}, arguments...) + } + for _, cmd := range []command.Command{ + nilCmd, fixedCmd, variadicCmd, } { - var ( - name = test.name - args = test.args - expected = test.expected - reason = test.reason - ) - t.Run(name, func(t *testing.T) { - t.Parallel() - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - err := cmds[name].Execute(ctx, args...) - if !errors.Is(err, expected) { - t.Errorf("did not receive expected error"+ - "\n\tgot: %s"+ - "\n\twant: %s"+ - "\n\twhy: %s", - err, expected, reason, - ) + for _, arguments := range subnames { + if err := cmd.Execute(ctx, arguments...); err != nil { + t.Error(err) } - }) + } + } +} + +func subcommandInvalid(t *testing.T) { + t.Parallel() + var ( + cmd = newTestSubcommands(t) + ctx = context.Background() + ) + if err := cmd.Execute(ctx); err == nil { + t.Error( + "subcommand group is expected to return `UsageError`" + + "when called directly, but returned nil", + ) + } + testErrorParameters(t, cmd) +} + +func rendererTest(t *testing.T) { + const ( + glamourStyleKey = `GLAMOUR_STYLE` + helpFlag = "-help" + renderFlag = "-video-terminal=true" + ) + var ( + ctx = context.Background() + cmd = newNiladicTestCommand(t) + flags = []string{helpFlag, renderFlag} + ) + t.Run("valid", func(t *testing.T) { + const style = "dark" + if err := os.Setenv(glamourStyleKey, style); err != nil { + t.Error(err) + } + if err := cmd.Execute(ctx, flags...); err != nil && + !errors.Is(err, flag.ErrHelp) { + t.Error(err) + } + }) + t.Run("invalid", func(t *testing.T) { + const style = "invalid-style-path.json" + if err := os.Setenv(glamourStyleKey, style); err != nil { + t.Error(err) + } + if err := cmd.Execute(ctx, flags...); err == nil { + t.Error("expected error but received none - " + + "invalid theme used with renderer") + } + }) + if err := os.Unsetenv(glamourStyleKey); err != nil { + t.Error(err) } } diff --git a/internal/command/doc.go b/internal/command/doc.go new file mode 100644 index 00000000..19fb0bc9 --- /dev/null +++ b/internal/command/doc.go @@ -0,0 +1,3 @@ +// Package command generates (sub)commands +// from generic function signatures. +package command diff --git a/internal/command/example_fixed_test.go b/internal/command/example_fixed_test.go new file mode 100644 index 00000000..e33bbbb6 --- /dev/null +++ b/internal/command/example_fixed_test.go @@ -0,0 +1,105 @@ +package command_test + +import ( + "context" + "flag" + "fmt" + "os" + + "github.com/djdv/go-filesystem-utils/internal/command" +) + +// fixedType is the type our execute function +// expects to always receive when it's called. +// The type will already be populated with default +// values or values parsed from the arguments passed +// to [command.Execute]. +type fixedType struct { + someField int +} + +// BindFlags initializes default values for our type +// and gives the [flag] package the ability to overwrite +// them when parsing flags. +func (ft *fixedType) BindFlags(flagSet *flag.FlagSet) { + const ( + flagName = "flag" + flagUsage = "an example flag" + flagDefault = 1 + ) + flagSet.IntVar(&ft.someField, flagName, flagDefault, flagUsage) +} + +// MakeFixedCommand can be used to construct +// commands that expect a specific fixed type, +// and optionally, variadic arguments. +func ExampleMakeFixedCommand() { + var ( + cmd = newFixedCommand() + cmdArgs = newFixedArgsCommand() + ctx = context.TODO() + ) + if err := cmd.Execute(ctx); err != nil { + fmt.Fprint(os.Stderr, err) + return + } + if err := cmd.Execute(ctx, "-flag=2"); err != nil { + fmt.Fprint(os.Stderr, err) + return + } + if err := cmdArgs.Execute(ctx, "-flag=3"); err != nil { + fmt.Fprint(os.Stderr, err) + return + } + if err := cmdArgs.Execute(ctx, "-flag=4", "a", "b", "c"); err != nil { + fmt.Fprint(os.Stderr, err) + return + } + // Output: + // settings.someField: 1 + // settings.someField: 2 + // settings.someField: 3 + // settings.someField: 4 + // arguments: [a b c] +} + +func newFixedCommand() command.Command { + const ( + name = "fixed" + synopsis = "Prints a value." + usage = "Call the command with or" + + " without flags" + ) + return command.MakeFixedCommand[*fixedType]( + name, synopsis, usage, + fixedExecute, + ) +} + +func fixedExecute(ctx context.Context, settings *fixedType) error { + fmt.Printf("settings.someField: %d\n", settings.someField) + return nil +} + +func newFixedArgsCommand() command.Command { + const ( + name = "fixed-args" + synopsis = "Prints a value and arguments." + usage = "Call the command with or" + + " without flags or arguments" + ) + return command.MakeFixedCommand[*fixedType]( + name, synopsis, usage, + fixedExecuteArgs, + ) +} + +func fixedExecuteArgs(ctx context.Context, settings *fixedType, arguments ...string) error { + if err := fixedExecute(ctx, settings); err != nil { + return nil + } + if len(arguments) > 0 { + fmt.Printf("arguments: %v\n", arguments) + } + return nil +} diff --git a/internal/command/example_niladic_test.go b/internal/command/example_niladic_test.go new file mode 100644 index 00000000..43dd8428 --- /dev/null +++ b/internal/command/example_niladic_test.go @@ -0,0 +1,42 @@ +package command_test + +import ( + "context" + "fmt" + "os" + + "github.com/djdv/go-filesystem-utils/internal/command" +) + +// MakeNiladicCommand can be used to construct +// basic commands that don't expect additional +// parameters to be passed to their execute function. +func ExampleMakeNiladicCommand() { + var ( + cmd = newNiladicCommand() + ctx = context.TODO() + ) + if err := cmd.Execute(ctx); err != nil { + fmt.Fprint(os.Stderr, err) + return + } + // Output: + // hello! +} + +func newNiladicCommand() command.Command { + const ( + name = "niladic" + synopsis = "Prints a message." + usage = "Call the command with no arguments" + ) + return command.MakeNiladicCommand( + name, synopsis, usage, + niladicExecute, + ) +} + +func niladicExecute(context.Context) error { + fmt.Println("hello!") + return nil +} diff --git a/internal/command/example_subcommands_test.go b/internal/command/example_subcommands_test.go new file mode 100644 index 00000000..bcd2413e --- /dev/null +++ b/internal/command/example_subcommands_test.go @@ -0,0 +1,118 @@ +package command_test + +import ( + "context" + "os" + + "github.com/djdv/go-filesystem-utils/internal/command" +) + +// Subcommand groups can be useful for defining +// a section of related commands under a single +// named group. +func ExampleSubcommandGroup() { + var ( + cmd = newSubcommands() + ctx = context.TODO() + ) + // NOTE: text rendering is disabled + // for `go test`'s output comparison. + // Normally this can be omitted. + const ( + helpFlag = "-help" + renderFlag = "-video-terminal=false" + ) + cmd.Execute(ctx, helpFlag, renderFlag) + cmd.Execute(ctx, "alphabets", helpFlag, renderFlag) + cmd.Execute(ctx, "numerals", helpFlag, renderFlag) + // Output: + // Must be called with a subcommand. + // + // Usage: + // main subcommand [flags] + // Flags: + // -help + // prints out this help text + // -video-terminal + // render text for video terminals + // (default: true) + // Subcommands: + // alphabets - Letter group. + // numerals - Number group. + // + // Must be called with a subcommand. + // + // Usage: + // alphabets subcommand [flags] + // Flags: + // -help + // prints out this help text + // -video-terminal + // render text for video terminals + // (default: true) + // Subcommands: + // a - a synopsis + // b - b synopsis + // c - c synopsis + // + // Must be called with a subcommand. + // + // Usage: + // numerals subcommand [flags] + // Flags: + // -help + // prints out this help text + // -video-terminal + // render text for video terminals + // (default: true) + // Subcommands: + // 1 - 1 synopsis + // 2 - 2 synopsis + // 3 - 3 synopsis +} + +func newSubcommands() command.Command { + var ( + noopFn = func(context.Context) error { return nil } + makeCommand = func(name string) command.Command { + var ( + synopsis = name + " synopsis" + usage = name + " usage" + ) + return command.MakeNiladicCommand( + name, synopsis, usage, noopFn, + ) + } + // Printer output defaults to [os.Stderr]. + // We set it here only because `go test` + // compares against [os.Stdout]. + output = os.Stdout + cmdOptions = []command.Option{ + command.WithUsageOutput(output), + } + ) + return command.SubcommandGroup( + "main", "Top level group", + []command.Command{ + command.SubcommandGroup( + "alphabets", "Letter group.", + []command.Command{ + makeCommand("a"), + makeCommand("b"), + makeCommand("c"), + }, + cmdOptions..., + ), + command.SubcommandGroup( + "numerals", "Number group.", + []command.Command{ + makeCommand("1"), + makeCommand("2"), + makeCommand("3"), + }, + cmdOptions..., + ), + }, + cmdOptions..., + ) +} diff --git a/internal/command/example_variadic_test.go b/internal/command/example_variadic_test.go new file mode 100644 index 00000000..ec30a500 --- /dev/null +++ b/internal/command/example_variadic_test.go @@ -0,0 +1,128 @@ +package command_test + +import ( + "context" + "flag" + "fmt" + "os" + "strconv" + + "github.com/djdv/go-filesystem-utils/internal/command" +) + +type ( + // settings is the type our execute function + // will construct on its own. + settings struct { + someField int + } + // Execute expects to receive a list of `option`s + // that it will apply to the [settings] struct. + option func(*settings) error + // options holds individual [option] values + // and also satisfies the [command.ExecuteType] constraint. + options []option +) + +const variadicFlagDefault = 1 + +// BindFlags gives the [flag] package the ability to +// append options to our list during flag parsing. +func (ol *options) BindFlags(flagSet *flag.FlagSet) { + const ( + flagName = "flag" + flagUsage = "an example flag" + ) + flagSet.Func(flagName, flagUsage, func(parameter string) error { + parsedValue, err := strconv.Atoi(parameter) + if err != nil { + return err + } + *ol = append(*ol, func(settings *settings) error { + settings.someField = parsedValue + return nil + }) + return nil + }) +} + +// MakeVariadicCommand can be used to construct +// commands that expect a variable amount of parameters. +func ExampleMakeVariadicCommand() { + var ( + cmd = newVariadicCommand() + cmdArgs = newVariadicArgsCommand() + ctx = context.TODO() + ) + if err := cmd.Execute(ctx); err != nil { + fmt.Fprint(os.Stderr, err) + return + } + if err := cmd.Execute(ctx, "-flag=2"); err != nil { + fmt.Fprint(os.Stderr, err) + return + } + if err := cmdArgs.Execute(ctx, "-flag=3"); err != nil { + fmt.Fprint(os.Stderr, err) + return + } + if err := cmdArgs.Execute(ctx, "-flag=4", "a", "b", "c"); err != nil { + fmt.Fprint(os.Stderr, err) + return + } + // Output: + // settings.someField: 1 + // settings.someField: 2 + // settings.someField: 3 + // settings.someField: 4 + // arguments: [a b c] +} + +func newVariadicCommand() command.Command { + const ( + name = "variadic" + synopsis = "Prints a value." + usage = "Call the command with or" + + " without flags" + ) + return command.MakeVariadicCommand[options]( + name, synopsis, usage, + variadicExecute, + ) +} + +func variadicExecute(ctx context.Context, options ...option) error { + settings := settings{ + someField: variadicFlagDefault, + } + for _, apply := range options { + if err := apply(&settings); err != nil { + return err + } + } + fmt.Printf("settings.someField: %d\n", settings.someField) + return nil +} + +func newVariadicArgsCommand() command.Command { + const ( + name = "variadic-args" + synopsis = "Prints a value and arguments." + usage = "Call the command with or" + + " without flags or arguments" + ) + return command.MakeVariadicCommand[options]( + name, synopsis, usage, + variadicExecuteArgs, + ) +} + +func variadicExecuteArgs(ctx context.Context, arguments []string, options ...option) error { + if err := variadicExecute(ctx, options...); err != nil { + return nil + } + if len(arguments) > 0 { + fmt.Printf("arguments: %v\n", arguments) + } + return nil +} diff --git a/internal/command/fixed.go b/internal/command/fixed.go new file mode 100644 index 00000000..292db9c6 --- /dev/null +++ b/internal/command/fixed.go @@ -0,0 +1,117 @@ +package command + +import ( + "context" + "flag" +) + +type ( + // ExecuteMonadic permits functions + // with these signatures. + ExecuteMonadic[ + ET ExecuteType[T], + T any, + ] interface { + ExecuteMonadicFunc[ET, T] | + ExecuteDyadicFunc[ET, T] + } + + // ExecuteMonadicFunc is a variant of [ExecuteNiladicFunc] + // that also accepts an [ExecuteType]. + ExecuteMonadicFunc[ + ET ExecuteType[T], + T any, + ] interface { + func(context.Context, ET) error + } + + // ExecuteDyadicFunc is a variant of [ExecuteMonadicFunc] + // that also accepts variadic arguments. + ExecuteDyadicFunc[ + ET ExecuteType[T], + T any, + ] interface { + func(context.Context, ET, ...string) error + } + + fixedCommand[ + ET ExecuteType[T], T any, + EC ExecuteMonadic[ET, T], + ] struct { + executeFn EC + commandCommon + } +) + +// MakeFixedCommand wraps a function which +// accepts either [ExecuteType] or, +// [ExecuteType] and variadic string parameters, +// which are passed to `executeFn` during [command.Execute]. +func MakeFixedCommand[ + ET ExecuteType[T], + EC ExecuteMonadic[ET, T], + T any, +]( + name, synopsis, usage string, + executeFn EC, options ...Option, +) Command { + cmd := fixedCommand[ET, T, EC]{ + commandCommon: commandCommon{ + name: name, + synopsis: synopsis, + usage: usage, + }, + executeFn: executeFn, + } + applyOptions(&cmd.commandCommon, options...) + return &cmd +} + +func (fc *fixedCommand[ET, T, EC]) acceptsArgs() bool { + _, haveArgs := any(fc.executeFn).(func(context.Context, ET, ...string) error) + return haveArgs +} + +func (fc *fixedCommand[ET, T, EC]) Execute(ctx context.Context, args ...string) error { + if subcommand, subargs := getSubcommand(fc, args); subcommand != nil { + return subcommand.Execute(ctx, subargs...) + } + var ( + flagSet = newFlagSet(fc.name) + settings T + ) + ET(&settings).BindFlags(flagSet) + needHelp, err := fc.parseFlags(flagSet, args...) + if err != nil { + return err + } + if needHelp { + err = flag.ErrHelp + } else { + err = fc.execute(ctx, flagSet, &settings) + } + if err != nil { + acceptsArgs := fc.acceptsArgs() + return fc.maybePrintUsage(err, acceptsArgs, flagSet) + } + return nil +} + +func (fc *fixedCommand[ET, T, EC]) execute(ctx context.Context, flagSet *flag.FlagSet, settings ET) error { + var ( + arguments = flagSet.Args() + haveArgs = len(arguments) > 0 + execErr error + ) + switch execFn := any(fc.executeFn).(type) { + case func(context.Context, ET) error: + if haveArgs { + execErr = unexpectedArguments(fc.name, arguments) + break + } + execErr = execFn(ctx, settings) + case func(context.Context, ET, ...string) error: + execErr = execFn(ctx, settings, arguments...) + } + return execErr +} diff --git a/internal/command/help.go b/internal/command/help.go deleted file mode 100644 index 59ad1ba1..00000000 --- a/internal/command/help.go +++ /dev/null @@ -1,35 +0,0 @@ -package command - -import ( - "flag" - "strconv" -) - -type ( - // HelpArg implements [HelpFlag]. - HelpArg bool - - // HelpFlag providers a getter for a `-help` flag. - HelpFlag interface { - Help() bool - } -) - -func (b HelpArg) Help() bool { return bool(b) } -func (b *HelpArg) String() string { return strconv.FormatBool(bool(*b)) } - -// BindFlags defines a `-help` flag in the [flag.FlagSet]. -func (b *HelpArg) BindFlags(fs *flag.FlagSet) { - const usage = "Prints out this help text." - fs.BoolVar((*bool)(b), "help", false, usage) -} - -// Set parses boolean strings into [HelpArg]. -func (b *HelpArg) Set(str string) error { - val, err := strconv.ParseBool(str) - if err != nil { - return err - } - *b = HelpArg(val) - return nil -} diff --git a/internal/command/help_test.go b/internal/command/help_test.go deleted file mode 100644 index a927f009..00000000 --- a/internal/command/help_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package command_test - -import ( - "fmt" - "testing" - - "github.com/djdv/go-filesystem-utils/internal/command" -) - -func TestHelp(t *testing.T) { - t.Parallel() - t.Run("HelpFlag", HelpArg) -} - -// HelpArg tests if `-help` text has the correct output -func HelpArg(t *testing.T) { - t.Parallel() - for _, test := range []struct { - want bool - }{ - {true}, - {false}, - } { - - want := test.want - t.Run(fmt.Sprint(want), func(t *testing.T) { - t.Parallel() - var ( - helpArg = new(command.HelpArg) - stringWant = fmt.Sprint(want) - ) - if err := helpArg.Set(stringWant); err != nil { - t.Fatal(err) - } - if got := helpArg.Help(); got != want { - t.Errorf("helpflag mismatch"+ - "\n\tgot: %t"+ - "\n\twant: %t", - got, want, - ) - } - if got := helpArg.String(); got != stringWant { - t.Errorf("helpflag format mismatch"+ - "\n\tgot: %s"+ - "\n\twant: %s", - got, stringWant, - ) - } - }) - } -} diff --git a/internal/command/interface.go b/internal/command/interface.go deleted file mode 100644 index 85d6667a..00000000 --- a/internal/command/interface.go +++ /dev/null @@ -1,49 +0,0 @@ -package command - -import ( - "context" - "flag" - - "github.com/djdv/go-filesystem-utils/internal/generic" -) - -type ( - Command interface { - // Name returns a human friendly name of the command. - // Which may be used to identify commands, - // as well as decorate user facing help-text. - Name() string - - // Synopsis returns a single-line string describing the command. - Synopsis() string - - // Usage returns an arbitrarily long string explaining how to use the command. - Usage() string - - // Subcommands returns a list of subcommands (if any). - Subcommands() []Command - - // Execute executes the command, with or without any arguments. - Execute(ctx context.Context, args ...string) error - } - - // A FlagBinder should call the relevant `Var` methods of the [flag.FlagSet], - // with each of it's flag variable references. - // E.g. a struct would pass pointers to each of its fields, - // to `FlagSet.Var(&structABC.fieldXYZ, ...)`. - FlagBinder interface { - BindFlags(*flag.FlagSet) - } - - // FlagSettings is a constraint that permits any reference type - // that can bind its value setter(s), to a [flag.FlagSet]. - FlagSettings[settings any] interface { - *settings - FlagBinder - } -) - -// ErrUsage may be returned from Execute if the provided arguments -// do not match the expectations of the given command. -// E.g. arguments in the wrong format/type, too few/many arguments, etc. -const ErrUsage = generic.ConstError("command called with unexpected arguments") diff --git a/internal/command/niladic.go b/internal/command/niladic.go new file mode 100644 index 00000000..26d62af3 --- /dev/null +++ b/internal/command/niladic.go @@ -0,0 +1,69 @@ +package command + +import ( + "context" + "flag" +) + +type ( + // ExecuteNiladicFunc may be used + // as a command's Execute function. + ExecuteNiladicFunc func(context.Context) error + niladicCommand struct { + executeFn ExecuteNiladicFunc + commandCommon + } +) + +// MakeNiladicCommand returns a command +// that wraps `executeFn`. +func MakeNiladicCommand( + name, synopsis, usage string, + executeFn ExecuteNiladicFunc, + options ...Option, +) Command { + cmd := niladicCommand{ + commandCommon: commandCommon{ + name: name, + synopsis: synopsis, + usage: usage, + }, + executeFn: executeFn, + } + applyOptions(&cmd.commandCommon, options...) + return &cmd +} + +func (nc *niladicCommand) Execute(ctx context.Context, args ...string) error { + if subcommand, subargs := getSubcommand(nc, args); subcommand != nil { + return subcommand.Execute(ctx, subargs...) + } + var ( + flagSet = newFlagSet(nc.name) + needHelp, err = nc.parseFlags(flagSet, args...) + ) + if err != nil { + return err + } + if needHelp { + err = flag.ErrHelp + } else { + err = nc.execute(ctx, flagSet) + } + if err != nil { + const acceptsArgs = false + return nc.maybePrintUsage(err, acceptsArgs, flagSet) + } + return nil +} + +func (nc *niladicCommand) execute(ctx context.Context, flagSet *flag.FlagSet) error { + var ( + arguments = flagSet.Args() + haveArgs = len(arguments) > 0 + ) + if haveArgs { + return unexpectedArguments(nc.name, arguments) + } + return nc.executeFn(ctx) +} diff --git a/internal/command/options.go b/internal/command/options.go deleted file mode 100644 index 13188f2d..00000000 --- a/internal/command/options.go +++ /dev/null @@ -1,38 +0,0 @@ -package command - -type ( - Option func(*commandSettings) error - - commandSettings struct { - usageOutput StringWriter - subcommands []Command - } -) - -func WithSubcommands(subcommands ...Command) Option { - return func(settings *commandSettings) error { - settings.subcommands = subcommands - return nil - } -} - -// TODO: docs; this is where the usage text gets printed -// when args are not what execute() expects for the command. -// Note that we're going to resolve nil to stderr -// so make the user aware of this. -func WithUsageOutput(output StringWriter) Option { - return func(settings *commandSettings) error { - settings.usageOutput = output - return nil - } -} - -func parseOptions(options ...Option) (*commandSettings, error) { - set := new(commandSettings) - for _, setFunc := range options { - if err := setFunc(set); err != nil { - return nil, err - } - } - return set, nil -} diff --git a/internal/command/print.go b/internal/command/print.go deleted file mode 100644 index d5829d63..00000000 --- a/internal/command/print.go +++ /dev/null @@ -1,100 +0,0 @@ -package command - -import ( - "flag" - "fmt" - "io" - "text/tabwriter" -) - -type StringWriter interface { - io.Writer - io.StringWriter -} - -// printHelpText formats `-help` text. -func printHelpText(output StringWriter, - name, usage string, - flagSet *flag.FlagSet, subcommands ...Command, -) error { - var ( - haveFlags bool - haveSubs = len(subcommands) > 0 - ) - flagSet.VisitAll(func(*flag.Flag) { haveFlags = true }) - if err := printUsage(output, name, usage, haveFlags, haveSubs); err != nil { - return err - } - if err := printFlagHelp(output, flagSet); err != nil { - return err - } - if haveSubs { - return printSubcommandHelp(output, subcommands...) - } - return nil -} - -// printUsage formats the command's usage string. -// i.e. Usage: name [FLAGS] | Usage: name [FLAG] SUBCOMMAND -func printUsage(output io.StringWriter, - name, usage string, haveFlags, haveSubs bool, -) error { - if _, err := output.WriteString("Usage: " + name); err != nil { - return err - } - if haveFlags { - if _, err := output.WriteString(" [FLAGS]"); err != nil { - return err - } - } - if haveSubs { - if _, err := output.WriteString(" SUBCOMMAND"); err != nil { - return err - } - } - if _, err := output.WriteString("\n\n" + usage + "\n\n"); err != nil { - return err - } - return nil -} - -// printFlagHelp formats [FlagSet]. -func printFlagHelp(output StringWriter, flagSet *flag.FlagSet) error { - defer flagSet.SetOutput(flagSet.Output()) - if _, err := output.WriteString("Flags:\n"); err != nil { - return err - } - flagSet.SetOutput(output) - flagSet.PrintDefaults() - if _, err := output.WriteString("\n"); err != nil { - return err - } - return nil -} - -// printSubcommandHelp creates list of subcommands formatted as 'name - synopsis`. -func printSubcommandHelp(output StringWriter, subs ...Command) error { - if _, err := output.WriteString("Subcommands:\n"); err != nil { - return err - } - var ( - tabWriter = tabwriter.NewWriter(output, 0, 0, 0, ' ', 0) - subTail = len(subs) - 1 - ) - - for i, sub := range subs { - if _, err := fmt.Fprintf(tabWriter, - " %s\t - %s\n", - sub.Name(), sub.Synopsis(), - ); err != nil { - return err - } - if i == subTail { - fmt.Fprintln(tabWriter) - } - } - if err := tabWriter.Flush(); err != nil { - return err - } - return nil -} diff --git a/internal/command/terminal.go b/internal/command/terminal.go new file mode 100644 index 00000000..221295dd --- /dev/null +++ b/internal/command/terminal.go @@ -0,0 +1,130 @@ +package command + +import ( + "fmt" + "os" + "reflect" + "unsafe" + + "github.com/charmbracelet/glamour" + "github.com/charmbracelet/glamour/ansi" + "github.com/muesli/termenv" +) + +func newRenderer() (*glamour.TermRenderer, error) { + const ( + glamourStyleKey = `GLAMOUR_STYLE` + optionsLength = 3 + ) + var ( + glamourStyle = os.Getenv(glamourStyleKey) + haveEnvStyle = glamourStyle != "" + renderOptions = make([]glamour.TermRendererOption, optionsLength) + ) + renderOptions[0] = glamour.WithWordWrap(0) + renderOptions[1] = glamour.WithPreservedNewLines() + if haveEnvStyle { + renderOptions[2] = glamour.WithEnvironmentConfig() + } else { + renderOptions[2] = glamour.WithStyles(makeStyle()) + } + // If this returns an error, the user + // most likely has invalid `GLAMOUR_STYLE` value. + renderer, err := glamour.NewTermRenderer(renderOptions...) + if err != nil { + const prefix = "could not initialize glamour renderer:" + var msg string + if haveEnvStyle { + msg = fmt.Sprintf( + "%s (`%s=%s`)", + prefix, glamourStyleKey, glamourStyle, + ) + } else { + msg = prefix + } + return nil, fmt.Errorf( + "%s %w", + msg, err, + ) + } + return renderer, nil +} + +func makeStyle() ansi.StyleConfig { + const ( + cornflowerBlue = "#6495ED" // 256: ~69 + jet = "#363636" // 256: ~237 + ) + var ( + style ansi.StyleConfig + codeFg, codeBg string + ) + if termenv.HasDarkBackground() { + style = glamour.DarkStyleConfig + style.CodeBlock.Theme = "monokai" + codeFg = cornflowerBlue + codeBg = jet + } else { + style = glamour.LightStyleConfig + style.CodeBlock.Theme = "monokailight" + codeFg = cornflowerBlue + codeBg = *style.Code.BackgroundColor + } + for _, block := range []*ansi.StyleBlock{ + &style.H1, + &style.H2, + &style.H3, + &style.H4, + &style.H5, + &style.H6, + &style.Code, + } { + // No padding. + block.Prefix = "" + block.Suffix = "" + } + // Remove baked preset chroma. + // (Causes `.Theme` to be processed) + style.CodeBlock.Chroma = nil + // Override preset code block color. + style.Code.Color = &codeFg + style.Code.BackgroundColor = &codeBg + // Assume operator's text color + // is already what they want it to be. + style.Document.Color = nil + style.Text.Color = nil + // Remove automatic line spacing. + style.Document.BlockPrefix = "" + style.Document.BlockSuffix = "" + // No margins. + var margin uint + style.Document.Margin = &margin + return style +} + +func bold(text string) string { + return "**" + text + "**" +} + +func italic(text string) string { + return "*" + text + "*" +} + +// HACK: +// We need to read a color value from the renderers +// style sheet, but this is not exposed. +// (See: [newItalicUnderlineRenderer].) +func _extractStyle(renderer *glamour.TermRenderer) *ansi.StyleConfig { + const fieldName = "ansiOptions" + var ( // XXX: Circumventing the type system. + options = reflect.ValueOf(renderer). + Elem(). + FieldByName(fieldName) + styles = reflect.NewAt( + options.Type(), + unsafe.Pointer(options.UnsafeAddr())). + Elem(). + Interface().(ansi.Options).Styles + ) + return &styles +} diff --git a/internal/command/terminal_other.go b/internal/command/terminal_other.go new file mode 100644 index 00000000..54e38846 --- /dev/null +++ b/internal/command/terminal_other.go @@ -0,0 +1,10 @@ +//go:build !windows + +package command + +import ( + "io" + "os" +) + +func ansiStderr() io.Writer { return os.Stderr } diff --git a/internal/command/terminal_windows.go b/internal/command/terminal_windows.go new file mode 100644 index 00000000..9322a538 --- /dev/null +++ b/internal/command/terminal_windows.go @@ -0,0 +1,41 @@ +package command + +import ( + "io" + "os" + + "github.com/mattn/go-colorable" + "golang.org/x/sys/windows" +) + +const vt100Mode = windows.ENABLE_PROCESSED_OUTPUT | + windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING + +func ansiStderr() io.Writer { + var ( + hStderr = windows.Stderr + mode, err = getConsoleMode(hStderr) + ) + if err != nil { + return colorable.NewNonColorable(os.Stderr) + } + if !hasVirtualTerminalProcessing(mode) { + if err := enableVirtualTerminalProcessing(hStderr, mode); err != nil { + return colorable.NewColorable(os.Stderr) + } + } + return os.Stderr +} + +func getConsoleMode(handle windows.Handle) (mode uint32, err error) { + err = windows.GetConsoleMode(handle, &mode) + return +} + +func hasVirtualTerminalProcessing(mode uint32) bool { + return mode&vt100Mode == vt100Mode +} + +func enableVirtualTerminalProcessing(handle windows.Handle, mode uint32) error { + return windows.SetConsoleMode(handle, mode|vt100Mode) +} diff --git a/internal/command/variadic.go b/internal/command/variadic.go new file mode 100644 index 00000000..58c73ac5 --- /dev/null +++ b/internal/command/variadic.go @@ -0,0 +1,123 @@ +package command + +import ( + "context" + "flag" +) + +type ( + // ExecuteVariadic permits functions + // with these signatures. + ExecuteVariadic[ + ET ExecuteType[ST], + ST ~[]VT, + VT any, + ] interface { + ExecuteVariadicFunc[ET, ST, VT] | + ExecuteArgumentsVariadicFunc[ET, ST, VT] + } + + // ExecuteVariadicFunc is a variant of [ExecuteNiladicFunc] + // that also accepts a variadic [ExecuteType]. + ExecuteVariadicFunc[ + ET ExecuteType[ST], + ST ~[]VT, + VT any, + ] interface { + func(context.Context, ...VT) error + } + + // ExecuteArgumentsVariadicFunc is a variant of [ExecuteVariadicFunc] + // that also accepts arguments. + ExecuteArgumentsVariadicFunc[ + ET ExecuteType[ST], + ST ~[]VT, + VT any, + ] interface { + func(context.Context, []string, ...VT) error + } + + variadicCommand[ + TS ~[]VT, + VT any, + ET ExecuteType[TS], + EC ExecuteVariadic[ET, TS, VT], + ] struct { + executeFn EC + commandCommon + } +) + +// MakeVariadicCommand wraps a function which +// accepts either variaic [ExecuteType] or, +// a slice of string parameters and variadic [ExecuteType] +// which are passed to `executeFn` during [command.Execute]. +func MakeVariadicCommand[ + TS ~[]T, + ET ExecuteType[TS], + EC ExecuteVariadic[ET, TS, T], + T any, +]( + name, synopsis, usage string, + executeFn EC, options ...Option, +) Command { + cmd := variadicCommand[TS, T, ET, EC]{ + commandCommon: commandCommon{ + name: name, + synopsis: synopsis, + usage: usage, + }, + executeFn: executeFn, + } + applyOptions(&cmd.commandCommon, options...) + return &cmd +} + +func (vc *variadicCommand[TS, T, ET, EC]) acceptsArgs() bool { + _, haveArgs := any(vc.executeFn).(func(context.Context, []string, ...T) error) + return haveArgs +} + +func (vc *variadicCommand[TS, T, ET, EC]) Execute(ctx context.Context, args ...string) error { + if subcommand, subargs := getSubcommand(vc, args); subcommand != nil { + return subcommand.Execute(ctx, subargs...) + } + var ( + flagSet = newFlagSet(vc.name) + options TS + ) + ET(&options).BindFlags(flagSet) + needHelp, err := vc.parseFlags(flagSet, args...) + if err != nil { + return err + } + if needHelp { + err = flag.ErrHelp + } else { + err = vc.execute(ctx, flagSet, options...) + } + if err != nil { + acceptsArgs := vc.acceptsArgs() + return vc.maybePrintUsage(err, acceptsArgs, flagSet) + } + return nil +} + +func (vc *variadicCommand[TS, T, ET, EC]) execute(ctx context.Context, flagSet *flag.FlagSet, options ...T) error { + var ( + arguments = flagSet.Args() + haveArgs = len(arguments) > 0 + execErr error + ) + switch execFn := any(vc.executeFn).(type) { + case func(context.Context, ...T) error: + if haveArgs { + execErr = unexpectedArguments(vc.name, arguments) + break + } + execErr = execFn(ctx, options...) + case func(context.Context, []string, ...T) error: + execErr = execFn(ctx, arguments, options...) + } + return execErr +} diff --git a/internal/commands/attacher.go b/internal/commands/attacher.go new file mode 100644 index 00000000..7309387f --- /dev/null +++ b/internal/commands/attacher.go @@ -0,0 +1,418 @@ +package commands + +import ( + "errors" + "fmt" + "io" + "strings" + "sync" + + p9fs "github.com/djdv/go-filesystem-utils/internal/filesystem/9p" + perrors "github.com/djdv/p9/errors" + "github.com/djdv/p9/fsimpl/templatefs" + "github.com/djdv/p9/p9" +) + +// This name is arbitrary, but unlikely to collide. +// (a base58 NanoID of length 9) +// We use it as a special identifier to access +// an attach sessions error list. +// +// Rationale: the Plan 9 file protocol uses +// string values for errors. Unfortunately, the +// 9P2000 .u and .L variants use Unix `errorno`s +// for their error messages. +// These are sufficient for the operating system, but +// not for the system's operator(s). As a result, +// we store Go's native error values and allow clients +// to retrieve their string form via this file. +const errorsFileName = "⚠️ KsK5VBcSs" + +type ( + attacher struct { + path ninePath + root p9.File + } + errorSys struct { + file p9.File + path ninePath + errors *ringSync[error] + } + errorFile struct { + templatefs.NoopFile + errors *ringSync[error] + reader *strings.Reader + qid p9.QID + } + ringSync[T any] struct { + elements []T + start int + length int + capacity int + sync.Mutex + } +) + +var ( + _ p9.Attacher = (*attacher)(nil) + _ p9.File = (*errorSys)(nil) + _ p9.File = (*errorFile)(nil) +) + +func receiveError(attachRoot p9.File, srvErr error) error { + _, errs, err := attachRoot.Walk([]string{errorsFileName}) + fail := func(err error) error { + return fmt.Errorf("errno: %w"+ + "\ncould not retrieve error string: %w", + srvErr, err, + ) + } + if err != nil { + return fail(err) + } + errorBytes, err := p9fs.ReadAll(errs) + if err != nil { + return fail(err) + } + if len(errorBytes) > 0 { + return errors.New(string(errorBytes)) + } + return srvErr +} + +func newAttacher(path ninePath, root p9.File) *attacher { + return &attacher{path: path, root: root} +} + +func (at *attacher) Attach() (p9.File, error) { + _, file, err := at.root.Walk(nil) + if err != nil { + return nil, err + } + // Our API client should never need this + // much error history. We do few operations + // after attach, then detach. But external + // clients are potentially long lived, and + // could instigate infinite errors in a + // single session. + // TODO: we should modify the 9P library + // so that `Attach` gets passed the `AttachName`. + // Then we can just have a special handshake + // for our client that enables the error wrapping. + // Everyone else would get direct access. + // E.g. `Attach($errorsPrefix/$realName)`. + const maxErrors = 10 + fsys := &errorSys{ + path: at.path, + file: file, + errors: newRing[error](maxErrors), + } + return fsys, nil +} + +func (ef *errorSys) append(err error) { + // Ignore errors that have no additional context. + if _, ignore := err.(perrors.Errno); ignore { + return + } + joinError, ok := err.(interface { + Unwrap() []error + }) + if !ok { + ef.errors.append(err) + return + } + for _, e := range joinError.Unwrap() { + if _, ignore := e.(perrors.Errno); ignore { + continue + } + ef.errors.append(e) + } +} + +func (ef *errorSys) wrap(file p9.File) p9.File { + return &errorSys{ + path: ef.path, + file: file, + errors: ef.errors, + } +} + +func (ef *errorSys) Walk(names []string) ([]p9.QID, p9.File, error) { + if len(names) == 1 && names[0] == errorsFileName { + var ( + qid = p9.QID{ + Type: p9.TypeRegular, + Path: ef.path.Add(1), + } + qids = []p9.QID{qid} + file = &errorFile{ + qid: qid, + errors: ef.errors, + } + ) + return qids, file, nil + } + qids, file, err := ef.file.Walk(names) + if err != nil { + ef.append(err) + } + return qids, ef.wrap(file), err +} + +func (ef *errorSys) WalkGetAttr(names []string) ([]p9.QID, p9.File, p9.AttrMask, p9.Attr, error) { + qids, file, mask, attr, err := ef.file.WalkGetAttr(names) + if err != nil { + ef.append(err) + } + return qids, ef.wrap(file), mask, attr, err +} + +func (ef *errorSys) StatFS() (p9.FSStat, error) { + fsStat, err := ef.file.StatFS() + if err != nil { + ef.append(err) + } + return fsStat, err +} + +func (ef *errorSys) GetAttr(req p9.AttrMask) (p9.QID, p9.AttrMask, p9.Attr, error) { + qid, mask, attr, err := ef.file.GetAttr(req) + if err != nil { + ef.append(err) + } + return qid, mask, attr, err +} + +func (ef *errorSys) SetAttr(valid p9.SetAttrMask, attr p9.SetAttr) error { + err := ef.file.SetAttr(valid, attr) + if err != nil { + ef.append(err) + } + return err +} + +func (ef *errorSys) Close() error { + err := ef.file.Close() + if err != nil { + ef.append(err) + } + return err +} + +func (ef *errorSys) Open(mode p9.OpenFlags) (p9.QID, uint32, error) { + qid, fd, err := ef.file.Open(mode) + if err != nil { + ef.append(err) + } + return qid, fd, err +} + +func (ef *errorSys) ReadAt(p []byte, offset int64) (int, error) { + n, err := ef.file.ReadAt(p, offset) + if err != nil && !errors.Is(err, io.EOF) { + ef.append(err) + } + return n, err +} + +func (ef *errorSys) WriteAt(p []byte, offset int64) (int, error) { + n, err := ef.file.WriteAt(p, offset) + if err != nil { + ef.append(err) + } + return n, err +} + +func (ef *errorSys) FSync() error { + err := ef.file.FSync() + if err != nil { + ef.append(err) + } + return err +} + +func (ef *errorSys) Lock(pid int, locktype p9.LockType, flags p9.LockFlags, start, length uint64, client string) (p9.LockStatus, error) { + status, err := ef.file.Lock(pid, locktype, flags, start, length, client) + if err != nil { + ef.append(err) + } + return status, err +} + +func (ef *errorSys) Create(name string, flags p9.OpenFlags, permissions p9.FileMode, uid p9.UID, gid p9.GID) (p9.File, p9.QID, uint32, error) { + file, qid, fd, err := ef.file.Create(name, flags, permissions, uid, gid) + if err != nil { + ef.append(err) + } + return ef.wrap(file), qid, fd, err +} + +func (ef *errorSys) Mkdir(name string, permissions p9.FileMode, uid p9.UID, gid p9.GID) (p9.QID, error) { + qid, err := ef.file.Mkdir(name, permissions, uid, gid) + if err != nil { + ef.append(err) + } + return qid, err +} + +func (ef *errorSys) Symlink(oldName, newName string, uid p9.UID, gid p9.GID) (p9.QID, error) { + qid, err := ef.file.Symlink(oldName, newName, uid, gid) + if err != nil { + ef.append(err) + } + return qid, err +} + +func (ef *errorSys) Link(target p9.File, newName string) error { + err := ef.file.Link(target, newName) + if err != nil { + ef.append(err) + } + return err +} + +func (ef *errorSys) Mknod(name string, mode p9.FileMode, major, minor uint32, uid p9.UID, gid p9.GID) (p9.QID, error) { + qid, err := ef.file.Mknod(name, mode, major, minor, uid, gid) + if err != nil { + ef.append(err) + } + return qid, err +} + +func (ef *errorSys) Rename(newDir p9.File, newName string) error { + err := ef.file.Rename(newDir, newName) + if err != nil { + ef.append(err) + } + return err +} + +func (ef *errorSys) RenameAt(oldName string, newDir p9.File, newName string) error { + err := ef.file.RenameAt(oldName, newDir, newName) + if err != nil { + ef.append(err) + } + return err +} + +func (ef *errorSys) UnlinkAt(name string, flags uint32) error { + err := ef.file.UnlinkAt(name, flags) + if err != nil { + ef.append(err) + } + return err +} + +func (ef *errorSys) Readdir(offset uint64, count uint32) (p9.Dirents, error) { + dirents, err := ef.file.Readdir(offset, count) + if err != nil && !errors.Is(err, io.EOF) { + ef.append(err) + } + return dirents, err +} + +func (ef *errorSys) Readlink() (string, error) { + link, err := ef.file.Readlink() + if err != nil { + ef.append(err) + } + return link, err +} + +func (ef *errorSys) Renamed(newDir p9.File, newName string) { + ef.file.Renamed(newDir, newName) +} + +func (er *errorFile) GetAttr(req p9.AttrMask) (p9.QID, p9.AttrMask, p9.Attr, error) { + var ( + qid = er.qid + valid p9.AttrMask + attr p9.Attr + ) + if req.Mode { + valid.Mode, attr.Mode = true, p9.ModeRegular + } + if req.Size { + data := er.errorString() + valid.Size, attr.Size = true, uint64(len(data)) + } + return qid, valid, attr, nil +} + +func (er *errorFile) Walk(names []string) ([]p9.QID, p9.File, error) { + if len(names) > 0 { + return nil, nil, perrors.ENOTDIR + } + if er.opened() { + return nil, nil, perrors.EBUSY + } + clone := &errorFile{ + qid: er.qid, + errors: er.errors, + } + return nil, clone, nil +} + +func (er *errorFile) opened() bool { return er.reader != nil } + +func (er *errorFile) Open(p9.OpenFlags) (p9.QID, uint32, error) { + if er.opened() { + return p9.QID{}, 0, perrors.EBADF + } + data := er.errorString() + er.reader = strings.NewReader(data) + return er.qid, 0, nil +} + +func (er *errorFile) errorString() string { + var ( + errs = er.errors.slice() + unique = make([]string, 0, len(errs)) + set = make(map[string]struct{}) + ) + for _, err := range errs { + str := err.Error() + if _, ok := set[str]; !ok { + unique = append(unique, str) + set[str] = struct{}{} + } + } + return strings.Join(unique, "\n") +} + +func (er *errorFile) ReadAt(p []byte, offset int64) (int, error) { + if !er.opened() { + return -1, perrors.EBADF + } + return er.reader.ReadAt(p, offset) +} + +func newRing[T any](capacity int) *ringSync[T] { + return &ringSync[T]{capacity: capacity} +} + +func (rb *ringSync[T]) append(item T) { + rb.Lock() + defer rb.Unlock() + if rb.elements == nil { + rb.elements = make([]T, rb.capacity) + } + if rb.length < len(rb.elements) { + rb.elements[(rb.start+rb.length)%len(rb.elements)] = item + rb.length++ + } else { + rb.elements[rb.start] = item + rb.start = (rb.start + 1) % len(rb.elements) + } +} + +func (rb *ringSync[T]) slice() []T { + rb.Lock() + defer rb.Unlock() + slice := make([]T, rb.length) + for i := 0; i < rb.length; i++ { + slice[i] = rb.elements[(rb.start+i)%len(rb.elements)] + } + return slice +} diff --git a/internal/commands/client.go b/internal/commands/client.go new file mode 100644 index 00000000..f0a8ca22 --- /dev/null +++ b/internal/commands/client.go @@ -0,0 +1,305 @@ +package commands + +import ( + "errors" + "flag" + "fmt" + "io" + "log" + "os" + "path/filepath" + "strings" + "time" + + "github.com/adrg/xdg" + p9fs "github.com/djdv/go-filesystem-utils/internal/filesystem/9p" + "github.com/djdv/go-filesystem-utils/internal/generic" + "github.com/djdv/p9/p9" + "github.com/multiformats/go-multiaddr" + manet "github.com/multiformats/go-multiaddr/net" + "github.com/u-root/uio/ulog" +) + +type ( + Client p9.Client + clientSettings struct { + serviceMaddr multiaddr.Multiaddr + log ulog.Logger + exitInterval time.Duration + } + clientOption func(*clientSettings) error + clientOptions []clientOption +) + +const ( + exitIntervalDefault = 30 * time.Second + errServiceConnection = generic.ConstError("could not connect to service") + errCouldNotDial = generic.ConstError("could not dial") +) + +func (cs *clientSettings) getClient(autoLaunchDaemon bool) (*Client, error) { + var ( + serviceMaddr = cs.serviceMaddr + options []p9.ClientOpt + ) + if log := cs.log; log != nil { + options = append(options, p9.WithClientLogger(log)) + } + var serviceMaddrs []multiaddr.Multiaddr + if serviceMaddr != nil { + autoLaunchDaemon = false + serviceMaddrs = []multiaddr.Multiaddr{serviceMaddr} + } else { + var err error + if serviceMaddrs, err = allServiceMaddrs(); err != nil { + return nil, fmt.Errorf( + "%w: %w", + errServiceConnection, err, + ) + } + } + client, err := connect(serviceMaddrs, options...) + if err == nil { + return client, nil + } + if autoLaunchDaemon && + errors.Is(err, errCouldNotDial) { + return launchAndConnect(cs.exitInterval, options...) + } + return nil, err +} + +func (co *clientOptions) BindFlags(flagSet *flag.FlagSet) { + const ( + verboseName = "verbose" + verboseUsage = "enable client message logging" + ) + flagSetFunc(flagSet, verboseName, verboseUsage, co, + func(verbose bool, settings *clientSettings) error { + if verbose { + const ( + prefix = "⬇️ client - " + flags = 0 + ) + settings.log = log.New(os.Stderr, prefix, flags) + } + return nil + }) + const ( + exitName = exitAfterFlagName + exitUsage = "passed to the daemon command if we launch it" + + "\n(refer to daemon's helptext)" + ) + flagSetFunc(flagSet, exitName, exitUsage, co, + func(value time.Duration, settings *clientSettings) error { + settings.exitInterval = value + return nil + }) + flagSet.Lookup(exitName). + DefValue = exitIntervalDefault.String() + const serverUsage = "file system service `maddr`" + flagSetFunc(flagSet, serverFlagName, serverUsage, co, + func(value multiaddr.Multiaddr, settings *clientSettings) error { + settings.serviceMaddr = value + return nil + }) + serviceMaddrs, err := allServiceMaddrs() + if err != nil { + panic(err) + } + maddrStrings := make([]string, len(serviceMaddrs)) + for i, maddr := range serviceMaddrs { + maddrStrings[i] = "`" + maddr.String() + "`" + } + flagSet.Lookup(serverFlagName). + DefValue = fmt.Sprintf( + "one of: %s", + strings.Join(maddrStrings, ", "), + ) +} + +func (co clientOptions) make() (clientSettings, error) { + settings := clientSettings{ + exitInterval: exitIntervalDefault, + } + return settings, generic.ApplyOptions(&settings, co...) +} + +func (c *Client) getListeners() ([]multiaddr.Multiaddr, error) { + listenersDir, err := (*p9.Client)(c).Attach(listenersFileName) + if err != nil { + return nil, err + } + maddrs, err := p9fs.GetListeners(listenersDir) + if err != nil { + return nil, errors.Join(err, listenersDir.Close()) + } + return maddrs, listenersDir.Close() +} + +func launchAndConnect(exitInterval time.Duration, options ...p9.ClientOpt) (*Client, error) { + daemon, ipc, stderr, err := spawnDaemonProc(exitInterval) + if err != nil { + return nil, err + } + killProc := func() error { + return errors.Join( + maybeKill(daemon), + daemon.Process.Release(), + ) + } + maddrs, err := getListenersFromProc(ipc, stderr, options...) + if err != nil { + errs := []error{err} + if err := killProc(); err != nil { + errs = append(errs, err) + } + return nil, errors.Join(errs...) + } + client, err := connect(maddrs) + if err != nil { + return nil, errors.Join(err, killProc()) + } + if err := daemon.Process.Release(); err != nil { + // We can no longer call `Kill`, and stdio + // IPC is closed. Attempt to abort the service + // via the established socket connection. + errs := []error{err} + if err := client.Shutdown(immediateShutdown); err != nil { + errs = append(errs, err) + } + if err := client.Close(); err != nil { + errs = append(errs, err) + } + return nil, errors.Join(errs...) + } + return client, nil +} + +func Connect(serverMaddr multiaddr.Multiaddr, options ...p9.ClientOpt) (*Client, error) { + return connect([]multiaddr.Multiaddr{serverMaddr}, options...) +} + +func connect(maddrs []multiaddr.Multiaddr, options ...p9.ClientOpt) (*Client, error) { + conn, err := firstDialable(maddrs...) + if err != nil { + return nil, fmt.Errorf( + "%w: %w", + errServiceConnection, err, + ) + } + return newClient(conn, options...) +} + +func newClient(conn io.ReadWriteCloser, options ...p9.ClientOpt) (*Client, error) { + client, err := p9.NewClient(conn, options...) + if err != nil { + return nil, fmt.Errorf( + "could not create client: %w", + err, + ) + } + return (*Client)(client), nil +} + +func allServiceMaddrs() ([]multiaddr.Multiaddr, error) { + var ( + userMaddrs, uErr = userServiceMaddrs() + systemMaddrs, sErr = systemServiceMaddrs() + serviceMaddrs = append(userMaddrs, systemMaddrs...) + ) + if err := errors.Join(uErr, sErr); err != nil { + return nil, fmt.Errorf( + "could not retrieve service maddrs: %w", + err, + ) + } + return serviceMaddrs, nil +} + +// TODO: [Ame] docs. +// userServiceMaddrs returns a list of multiaddrs that servers and client commands +// may try to use when hosting or querying a user-level file system service. +func userServiceMaddrs() ([]multiaddr.Multiaddr, error) { + return servicePathsToServiceMaddrs(xdg.StateHome, xdg.RuntimeDir) +} + +// TODO: [Ame] docs. +// systemServiceMaddrs returns a list of multiaddrs that servers and client commands +// may try to use when hosting or querying a system-level file system service. +func systemServiceMaddrs() ([]multiaddr.Multiaddr, error) { + return hostServiceMaddrs() +} + +func firstDialable(maddrs ...multiaddr.Multiaddr) (manet.Conn, error) { + for _, maddr := range maddrs { + if conn, err := manet.Dial(maddr); err == nil { + return conn, nil + } + } + return nil, fmt.Errorf( + "%w any of: %s", + errCouldNotDial, + formatMaddrs(maddrs), + ) +} + +func formatMaddrs(maddrs []multiaddr.Multiaddr) string { + maddrStrings := make([]string, len(maddrs)) + for i, maddr := range maddrs { + maddrStrings[i] = maddr.String() + } + return strings.Join(maddrStrings, ", ") +} + +func servicePathsToServiceMaddrs(servicePaths ...string) ([]multiaddr.Multiaddr, error) { + var ( + serviceMaddrs = make([]multiaddr.Multiaddr, 0, len(servicePaths)) + multiaddrSet = make(map[string]struct{}, len(servicePaths)) + ) + for _, servicePath := range servicePaths { + if _, alreadySeen := multiaddrSet[servicePath]; alreadySeen { + continue // Don't return duplicates in our slice. + } + multiaddrSet[servicePath] = struct{}{} + var ( + nativePath = filepath.Join(servicePath, serverRootName, serverName) + serviceMaddr, err = filepathToUnixMaddr(nativePath) + ) + if err != nil { + return nil, err + } + serviceMaddrs = append(serviceMaddrs, serviceMaddr) + } + return serviceMaddrs, nil +} + +func filepathToUnixMaddr(nativePath string) (multiaddr.Multiaddr, error) { + const ( + protocolPrefix = "/unix" + unixNamespace = len(protocolPrefix) + slash = 1 + ) + var ( + insertSlash = !strings.HasPrefix(nativePath, "/") + size = unixNamespace + len(nativePath) + ) + if insertSlash { + size += slash + } + // The component's protocol's value should be concatenated raw, + // with platform native conventions. I.e. avoid [path.Join]. + // For non-Unix formatted filepaths, we'll need to insert the multiaddr delimiter. + var maddrBuilder strings.Builder + maddrBuilder.Grow(size) + maddrBuilder.WriteString(protocolPrefix) + if insertSlash { + maddrBuilder.WriteRune('/') + } + maddrBuilder.WriteString(nativePath) + return multiaddr.NewMultiaddr(maddrBuilder.String()) +} + +func (c *Client) Close() error { + return (*p9.Client)(c).Close() +} diff --git a/internal/daemon/server_darwin.go b/internal/commands/client_darwin.go similarity index 70% rename from internal/daemon/server_darwin.go rename to internal/commands/client_darwin.go index db63c6f7..4c66edd9 100644 --- a/internal/daemon/server_darwin.go +++ b/internal/commands/client_darwin.go @@ -1,8 +1,8 @@ -package daemon +package commands import "github.com/multiformats/go-multiaddr" -func systemServiceMaddrs() ([]multiaddr.Multiaddr, error) { +func hostServiceMaddrs() ([]multiaddr.Multiaddr, error) { return servicePathsToServiceMaddrs( "/Library/Application Support", // NeXT "/var/run", // BSD UNIX diff --git a/internal/daemon/server_other.go b/internal/commands/client_other.go similarity index 62% rename from internal/daemon/server_other.go rename to internal/commands/client_other.go index 733dc2b8..80c0b2d3 100644 --- a/internal/daemon/server_other.go +++ b/internal/commands/client_other.go @@ -1,13 +1,12 @@ //go:build !darwin -// +build !darwin -package daemon +package commands import ( "github.com/adrg/xdg" "github.com/multiformats/go-multiaddr" ) -func systemServiceMaddrs() ([]multiaddr.Multiaddr, error) { +func hostServiceMaddrs() ([]multiaddr.Multiaddr, error) { return servicePathsToServiceMaddrs(xdg.ConfigDirs...) } diff --git a/internal/commands/daemon.go b/internal/commands/daemon.go index 977e9ef2..9123e66e 100644 --- a/internal/commands/daemon.go +++ b/internal/commands/daemon.go @@ -6,20 +6,21 @@ import ( "flag" "fmt" "io" + "io/fs" "log" + "net" "os" - "os/signal" + "reflect" + "strings" "sync" "sync/atomic" "time" "github.com/djdv/go-filesystem-utils/internal/command" - "github.com/djdv/go-filesystem-utils/internal/daemon" - "github.com/djdv/go-filesystem-utils/internal/files" + p9fs "github.com/djdv/go-filesystem-utils/internal/filesystem/9p" "github.com/djdv/go-filesystem-utils/internal/generic" - "github.com/djdv/go-filesystem-utils/internal/net" - srv9 "github.com/djdv/go-filesystem-utils/internal/net/9p" - "github.com/hugelgupf/p9/p9" + p9net "github.com/djdv/go-filesystem-utils/internal/net/9p" + "github.com/djdv/p9/p9" "github.com/multiformats/go-multiaddr" manet "github.com/multiformats/go-multiaddr/net" "github.com/u-root/uio/ulog" @@ -27,368 +28,1052 @@ import ( type ( daemonSettings struct { - serverMaddr multiaddr.Multiaddr - exitInterval time.Duration - uid p9.UID - gid p9.GID - commonSettings + systemLog, protocolLog ulog.Logger + serverMaddrs []multiaddr.Multiaddr + exitInterval time.Duration + nineIDs + permissions fs.FileMode } - serverHandleFunc = func(io.ReadCloser, io.WriteCloser) error + daemonOption func(*daemonSettings) error + daemonOptions []daemonOption + nineIDs struct { + uid p9.UID + gid p9.GID + } + ninePath = *atomic.Uint64 + fileSystem struct { + path ninePath + root p9.File + mount mountSubsystem + listen listenSubsystem + control controlSubsystem + } + mountSubsystem struct { + *p9fs.MountFile + name string + } + listenSubsystem struct { + *p9fs.Listener + listeners <-chan manet.Listener + cancel context.CancelFunc + name string + } + controlSubsystem struct { + directory p9.File + name string + shutdown + } + shutdown struct { + *p9fs.ChannelFile + ch <-chan []byte + cancel context.CancelFunc + name string + } + daemonSystem struct { + log ulog.Logger + files fileSystem + } + handleFunc = func(io.ReadCloser, io.WriteCloser) error + serveFunc = func(manet.Listener) error + checkFunc = func() (bool, shutdownDisposition, error) + + waitGroupChan[T any] struct { + ch chan T + closing chan struct{} + sync.WaitGroup + } + wgErrs = *waitGroupChan[error] + wgShutdown = *waitGroupChan[shutdownDisposition] +) + +const ( + daemonCommandName = "daemon" + apiUIDDefault = p9.NoUID + apiGIDDefault = p9.NoGID + apiPermissionsDefault = 0o751 + + errServe = generic.ConstError("encountered error while serving") + errShutdownDisposition = generic.ConstError("invalid shutdown disposition") ) -func (set *daemonSettings) BindFlags(flagSet *flag.FlagSet) { - set.commonSettings.BindFlags(flagSet) - multiaddrVar(flagSet, &set.serverMaddr, daemon.ServerName, - defaultServerMaddr{}, "Listening socket `maddr`.") - flagSet.DurationVar(&set.exitInterval, "exit-after", - 0, "Check every `interval` (e.g. \"30s\") if the service is active and exit if not.") - // TODO: default should be current user ids on unix, NoUID on NT. - uidVar(flagSet, &set.uid, "uid", - p9.NoUID, "file owner's `uid`") - gidVar(flagSet, &set.gid, "gid", - p9.NoGID, "file owner's `gid`") +func (do *daemonOptions) BindFlags(flagSet *flag.FlagSet) { + const ( + verboseName = "verbose" + verboseUsage = "enable server message logging" + ) + flagSetFunc(flagSet, verboseName, verboseUsage, do, + func(verbose bool, settings *daemonSettings) error { + if verbose { + const ( + prefix = "⬆️ server - " + flags = 0 + ) + settings.systemLog = log.New(os.Stderr, prefix, flags) + } + return nil + }) + const serverUsage = "listening socket `maddr`" + + "\ncan be specified multiple times and/or comma separated" + flagSetFunc(flagSet, serverFlagName, serverUsage, do, + func(value []multiaddr.Multiaddr, settings *daemonSettings) error { + settings.serverMaddrs = append(settings.serverMaddrs, value...) + return nil + }) + userMaddrs, err := userServiceMaddrs() + if err != nil { + panic(err) + } + flagSet.Lookup(serverFlagName). + DefValue = userMaddrs[0].String() + const ( + exitName = exitAfterFlagName + exitUsage = "check every `interval` (e.g. \"30s\") and shutdown the daemon if its idle" + ) + flagSetFunc(flagSet, exitName, exitUsage, do, + func(value time.Duration, settings *daemonSettings) error { + settings.exitInterval = value + return nil + }) + const ( + uidName = apiFlagPrefix + "uid" + uidUsage = "file owner's `uid`" + ) + flagSetFunc(flagSet, uidName, uidUsage, do, + func(value p9.UID, settings *daemonSettings) error { + settings.nineIDs.uid = value + return nil + }) + flagSet.Lookup(uidName). + DefValue = idString(apiUIDDefault) + const ( + gidName = apiFlagPrefix + "gid" + gidUsage = "file owner's `gid`" + ) + flagSetFunc(flagSet, gidName, gidUsage, do, + func(value p9.GID, settings *daemonSettings) error { + settings.nineIDs.gid = value + return nil + }) + flagSet.Lookup(gidName). + DefValue = idString(apiGIDDefault) + const ( + permissionsName = apiFlagPrefix + "permissions" + permissionsUsage = "`permissions` to use when creating service files" + ) + apiPermissions := fs.FileMode(apiPermissionsDefault) + flagSetFunc(flagSet, permissionsName, permissionsUsage, do, + func(value string, settings *daemonSettings) error { + permissions, err := parsePOSIXPermissions(apiPermissions, value) + if err != nil { + return err + } + apiPermissions = permissions &^ fs.ModeType + settings.permissions = apiPermissions + return nil + }) + flagSet.Lookup(permissionsName). + DefValue = modeToSymbolicPermissions(fs.FileMode(apiPermissionsDefault &^ p9.FileModeMask)) } +func (do daemonOptions) make() (daemonSettings, error) { + settings := daemonSettings{ + nineIDs: nineIDs{ + uid: apiUIDDefault, + gid: apiGIDDefault, + }, + permissions: apiPermissionsDefault, + } + if err := generic.ApplyOptions(&settings, do...); err != nil { + return daemonSettings{}, err + } + if settings.serverMaddrs == nil { + userMaddrs, err := userServiceMaddrs() + if err != nil { + return daemonSettings{}, err + } + settings.serverMaddrs = userMaddrs[0:1:1] + } + if settings.systemLog == nil { + settings.systemLog = ulog.Null + } + return settings, nil +} + +// Daemon constructs the command which +// hosts the file system service server. func Daemon() command.Command { const ( - name = "daemon" - synopsis = "Hosts the service." - usage = "Placeholder text." + name = daemonCommandName + synopsis = "Host system services." ) - return command.MakeCommand[*daemonSettings](name, synopsis, usage, daemonExecute) + usage := header("File system service daemon.") + + "\n\n" + synopsis + return command.MakeVariadicCommand[daemonOptions](name, synopsis, usage, daemonExecute) } -const ( - // TODO: These should be exported for clients. - // But need good names and docs. - // And docs that link "X is the [Y] fs" where Y links to docs - // for the Go and 9P interfaces of that FS. - // ^ We could also add special files to our APIs - // `/_$Some path nobody would ever use/manual.txt` - // Then the docs would move with the actual file itself, - // much like program help text does. - - listenerName = "listeners" - mounterName = "mounts" -) - -func daemonExecute(ctx context.Context, set *daemonSettings) error { +func daemonExecute(ctx context.Context, options ...daemonOption) error { + settings, err := daemonOptions(options).make() + if err != nil { + return err + } + dCtx, cancel := context.WithCancel(ctx) + defer cancel() + system, err := newSystem(dCtx, &settings) + if err != nil { + return err + } + const errBuffer = 0 var ( - serverMaddr = set.serverMaddr - srvLog = makeDaemonLog(set.verbose) + fsys = system.files + path = fsys.path + root = fsys.root + log = system.log + server = makeServer( + newAttacher(path, root), + settings.protocolLog, + ) + stopSend, + stopReceive = makeStoppers(ctx) + lsnStop, + srvStop, + mntStop = splitStopper(stopReceive) + listenSys = fsys.listen + listeners = listenSys.listeners + errs = newWaitGroupChan[error](errBuffer) ) - if lazy, ok := serverMaddr.(lazyFlag[multiaddr.Multiaddr]); ok { - var err error - if serverMaddr, err = lazy.get(); err != nil { - return err + handleListeners(server.Serve, listeners, errs, log) + go watchListenersStopper(listenSys.cancel, lsnStop, log) + serviceWg := handleStopSequence(dCtx, + server, srvStop, + fsys.mount, mntStop, + errs, log, + ) + var ( + listener = listenSys.Listener + permissions = modeFromFS(settings.permissions) + procExitCh = listenOn(listener, permissions, + stopSend, errs, + settings.serverMaddrs..., + ) + control = fsys.control.directory + handleFn = server.Handle + ) + setupIPCHandler(dCtx, procExitCh, + control, handleFn, + serviceWg, errs, + ) + idleCheckInterval := settings.exitInterval + setupExtraStopWriters(idleCheckInterval, &fsys, + stopSend, errs, + log, + ) + return watchService(ctx, serviceWg, + stopSend, errs, + log, + ) +} + +func watchService(ctx context.Context, + serviceWg *sync.WaitGroup, + stopSend wgShutdown, errs wgErrs, + log ulog.Logger, +) error { + go func() { + serviceWg.Wait() + stopSend.closeSend() + stopSend.waitThenCloseCh() + errs.waitThenCloseCh() + }() + var errSl []error + for err := range errs.ch { + log.Print(err) + errSl = append(errSl, err) + } + if errSl != nil { + return fmt.Errorf("daemon: %w", errors.Join(errSl...)) + } + return ctx.Err() +} + +func makeStoppers(ctx context.Context) (wgShutdown, <-chan shutdownDisposition) { + shutdownSend := newWaitGroupChan[shutdownDisposition](int(maximumShutdown)) + registerSystemStoppers(ctx, shutdownSend) + shutdownSend.Add(1) + go stopOnDone(ctx, shutdownSend) + shutdownReceive := make(chan shutdownDisposition) + go func() { + sequentialLeveling(shutdownSend.ch, shutdownReceive) + close(shutdownReceive) + }() + return shutdownSend, shutdownReceive +} + +func makeServer(fsys p9.Attacher, log ulog.Logger) *p9net.Server { + var options []p9net.ServerOpt + if log != nil { + options = []p9net.ServerOpt{ + p9net.WithServerLogger(log), } } + return p9net.NewServer(fsys, options...) +} + +func splitStopper(shutdownLevels <-chan shutdownDisposition) (_, _, _ <-chan shutdownDisposition) { + var lsnShutdownSignals, + srvShutdownSignals, + mntShutdownSignals <-chan shutdownDisposition + relayUnordered(shutdownLevels, &lsnShutdownSignals, + &srvShutdownSignals, &mntShutdownSignals) + return lsnShutdownSignals, srvShutdownSignals, mntShutdownSignals +} + +func handleListeners(serveFn serveFunc, + listeners <-chan manet.Listener, errs wgErrs, + log ulog.Logger, +) { + if log != nil && + log != ulog.Null { + var listenersDuplicate, + listenersLog <-chan manet.Listener + relayUnordered(listeners, + &listenersDuplicate, &listenersLog) + listeners = listenersDuplicate + go logListeners(log, listenersLog) + } + errs.Add(1) + go serveListeners(serveFn, listeners, errs) +} + +func handleStopSequence(ctx context.Context, + server *p9net.Server, srvStop <-chan shutdownDisposition, + mount mountSubsystem, mntStop <-chan shutdownDisposition, + errs wgErrs, log ulog.Logger, +) *sync.WaitGroup { + var serverWg, + mountWg sync.WaitGroup + errs.Add(2) + serverWg.Add(1) + mountWg.Add(1) + go func() { + defer serverWg.Done() + serverStopper(ctx, server, srvStop, errs, log) + }() + go func() { + serverWg.Wait() + unmountAll(mount, mntStop, errs, log) + mountWg.Done() + }() + return &mountWg +} + +func listenOn(listener *p9fs.Listener, permissions p9.FileMode, + stopper wgShutdown, + errs wgErrs, + maddrs ...multiaddr.Multiaddr, +) <-chan bool { var ( - serverWg sync.WaitGroup - server = &srv9.Server{ - // TODO: is a proper srv9.New func possible with our callbacks? - ListenerManager: new(net.ListenerManager), - } - serveErrs = make(chan error) - handleListener = func(listener manet.Listener) { - serverWg.Add(1) - srvLog.Print("listening on: ", listener.Multiaddr()) - go func() { - defer serverWg.Done() - for err := range server.Serve(ctx, listener) { - select { - case serveErrs <- err: - case <-ctx.Done(): - return - } - } - srvLog.Print("done listening on: ", listener.Multiaddr()) - }() + wg sync.WaitGroup + sawError atomic.Bool + processMaddr = func(maddr multiaddr.Multiaddr) { + defer func() { wg.Done(); stopper.Done(); errs.Done() }() + err := p9fs.Listen(listener, maddr, permissions) + if err != nil { + errs.send(fmt.Errorf( + "could not listen on: %s - %w", + maddr, err, + )) + stopper.send(patientShutdown) + sawError.Store(true) + } } - fsys, netsys = newSystems(set.uid, set.gid, handleListener) - sigCtx, sigCancel, interruptErrs = shutdownOnInterrupt(ctx, server.ListenerManager) - listener, err = netsys.Listen(serverMaddr) - errs = []<-chan error{serveErrs, interruptErrs} + maddrCount = len(maddrs) ) - if err != nil { - sigCancel() - // TODO: drain errs too? - return err + wg.Add(maddrCount) + stopper.Add(maddrCount) + errs.Add(maddrCount) + for _, maddr := range maddrs { + go processMaddr(maddr) } + failureSignal := make(chan bool, 1) + go func() { + defer close(failureSignal) + wg.Wait() + if sawError.Load() { + failureSignal <- true + } + }() + return failureSignal +} - server.Server = p9.NewServer(fsys, p9.WithServerLogger(srvLog)) - handleListener(listener) - go func() { defer sigCancel(); defer close(serveErrs); serverWg.Wait() }() +func setupIPCHandler(ctx context.Context, procExitCh <-chan bool, + control p9.File, handlerFn handleFunc, + serviceWg *sync.WaitGroup, errs wgErrs, +) { + if !isPipe(os.Stdin) { + return + } + serviceWg.Add(1) + errs.Add(1) + go handleStdio(ctx, procExitCh, + control, handlerFn, + serviceWg, errs, + ) +} - if isPipe(os.Stdin) { - errs = append(errs, handleStdio(sigCtx, server.Server)) +func setupExtraStopWriters( + idleCheck time.Duration, fsys *fileSystem, + stopper wgShutdown, + errs wgErrs, log ulog.Logger, +) { + shutdownFileData := fsys.control.shutdown.ch + stopper.Add(2) + errs.Add(2) + go stopOnUnreachable(fsys, stopper, errs, log) + go stopOnShutdownWrite(shutdownFileData, stopper, errs, log) + if idleCheck != 0 { + stopper.Add(1) + errs.Add(1) + idleCheckFn := makeIdleChecker(fsys, idleCheck, log) + go stopWhen(idleCheckFn, idleCheck, stopper, errs) } - if interval := set.exitInterval; interval != 0 { - errs = append(errs, shutdownOnIdle(ctx, interval, fsys, server.ListenerManager)) +} + +func newWaitGroupChan[T any](size int) *waitGroupChan[T] { + return &waitGroupChan[T]{ + ch: make(chan T), + closing: make(chan struct{}, size), } - return flattenErrs(errs...) } -func makeDaemonLog(verbose bool) ulog.Logger { - if verbose { - return log.New(os.Stdout, "⬆️ server - ", log.Lshortfile) +func (wc *waitGroupChan[T]) Closing() <-chan struct{} { + return wc.closing +} + +func (wc *waitGroupChan[T]) closeSend() { + close(wc.closing) +} + +func (wc *waitGroupChan[T]) send(value T) (sent bool) { + select { + case wc.ch <- value: + sent = true + case <-wc.closing: } - return ulog.Null + return sent +} + +func (wc *waitGroupChan[T]) waitThenCloseCh() { + wc.WaitGroup.Wait() + close(wc.ch) } -func shutdownOnInterrupt(ctx context.Context, listMan *net.ListenerManager) (context.Context, context.CancelFunc, <-chan error) { +func newSystem(ctx context.Context, set *daemonSettings) (*daemonSystem, error) { var ( - sigCtx, sigCancel = context.WithCancel(ctx) - interruptCount = signalCount(sigCtx, os.Interrupt) + uid = set.uid + gid = set.gid + fsys, err = newFileSystem(ctx, uid, gid) + system = &daemonSystem{ + files: fsys, + log: set.systemLog, + } ) - return sigCtx, sigCancel, shutdownWithCounter(sigCtx, sigCancel, interruptCount, listMan) + return system, err } -func signalCount(ctx context.Context, sig os.Signal) <-chan uint { +func newFileSystem(ctx context.Context, uid p9.UID, gid p9.GID) (fileSystem, error) { + const permissions = p9fs.ReadUser | p9fs.WriteUser | p9fs.ExecuteUser | + p9fs.ReadGroup | p9fs.ExecuteGroup | + p9fs.ReadOther | p9fs.ExecuteOther var ( - counter = make(chan uint) - signals = make(chan os.Signal, 1) + path = new(atomic.Uint64) + _, root, err = p9fs.NewDirectory( + p9fs.WithPath[p9fs.DirectoryOption](path), + p9fs.WithUID[p9fs.DirectoryOption](uid), + p9fs.WithGID[p9fs.DirectoryOption](gid), + p9fs.WithPermissions[p9fs.DirectoryOption](permissions), + p9fs.WithoutRename[p9fs.DirectoryOption](true), + ) ) - signal.Notify(signals, sig) - go func() { - defer close(counter) - defer close(signals) - defer signal.Ignore(sig) - var count uint - for { - select { - case <-signals: - count++ - select { - case counter <- count: - case <-ctx.Done(): - return - } - case <-ctx.Done(): - return - } - } - }() - return counter + if err != nil { + return fileSystem{}, err + } + mount, err := newMounter(root, path, uid, gid, permissions) + if err != nil { + return fileSystem{}, err + } + listen, err := newListener(ctx, root, path, uid, gid, permissions) + if err != nil { + return fileSystem{}, err + } + control, err := newControl(ctx, root, path, uid, gid, permissions) + if err != nil { + return fileSystem{}, err + } + system := fileSystem{ + path: path, + root: root, + mount: mount, + listen: listen, + control: control, + } + return system, linkSystems(&system) +} + +func newListener(ctx context.Context, parent p9.File, path ninePath, + uid p9.UID, gid p9.GID, permissions p9.FileMode, +) (listenSubsystem, error) { + lCtx, cancel := context.WithCancel(ctx) + _, listenFS, listeners, err := p9fs.NewListener(lCtx, + p9fs.WithParent[p9fs.ListenerOption](parent, listenersFileName), + p9fs.WithPath[p9fs.ListenerOption](path), + p9fs.WithUID[p9fs.ListenerOption](uid), + p9fs.WithGID[p9fs.ListenerOption](gid), + p9fs.WithPermissions[p9fs.ListenerOption](permissions), + p9fs.UnlinkEmptyChildren[p9fs.ListenerOption](true), + ) + if err != nil { + cancel() + return listenSubsystem{}, err + } + return listenSubsystem{ + name: listenersFileName, + Listener: listenFS, + listeners: listeners, + cancel: cancel, + }, nil } -func newSystems(uid p9.UID, gid p9.GID, netCallback files.ListenerCallback) (*files.Directory, *files.Listener) { - const permissions = files.S_IRWXU | - files.S_IRGRP | files.S_IXGRP | - files.S_IROTH | files.S_IXOTH +func newControl(ctx context.Context, + parent p9.File, path ninePath, + uid p9.UID, gid p9.GID, permissions p9.FileMode, +) (controlSubsystem, error) { + _, control, err := p9fs.NewDirectory( + p9fs.WithParent[p9fs.DirectoryOption](parent, controlFileName), + p9fs.WithPath[p9fs.DirectoryOption](path), + p9fs.WithUID[p9fs.DirectoryOption](uid), + p9fs.WithGID[p9fs.DirectoryOption](gid), + p9fs.WithPermissions[p9fs.DirectoryOption](permissions), + p9fs.WithoutRename[p9fs.DirectoryOption](true), + ) + if err != nil { + return controlSubsystem{}, err + } var ( - metaOptions = []files.MetaOption{ - files.WithPath(new(atomic.Uint64)), - files.WithBaseAttr(&p9.Attr{ - Mode: permissions, - UID: uid, - GID: gid, - }), - files.WithAttrTimestamps(true), - } - directoryOptions = []files.DirectoryOption{ - files.WithSuboptions[files.DirectoryOption](metaOptions...), - } - _, fsys = files.NewDirectory(directoryOptions...) - generatorOptions = []files.GeneratorOption{ - files.CleanupEmpties(true), - } - mounter = files.NewMounter( - files.WithSuboptions[files.MounterOption](metaOptions...), - files.WithSuboptions[files.MounterOption]( - files.WithParent(fsys, mounterName), - ), - files.WithSuboptions[files.MounterOption](generatorOptions...), - ) - _, listeners = files.NewListener(netCallback, - files.WithSuboptions[files.ListenerOption](metaOptions...), - files.WithSuboptions[files.ListenerOption]( - files.WithParent(fsys, listenerName), - ), - files.WithSuboptions[files.ListenerOption](generatorOptions...), - ) + sCtx, cancel = context.WithCancel(ctx) + filePermissions = permissions ^ (p9fs.ExecuteOther | p9fs.ExecuteGroup | p9fs.ExecuteUser) + ) + _, shutdownFile, shutdownCh, err := p9fs.NewChannelFile(sCtx, + p9fs.WithParent[p9fs.ChannelOption](control, shutdownFileName), + p9fs.WithPath[p9fs.ChannelOption](path), + p9fs.WithUID[p9fs.ChannelOption](uid), + p9fs.WithGID[p9fs.ChannelOption](gid), + p9fs.WithPermissions[p9fs.ChannelOption](filePermissions), ) + if err != nil { + cancel() + return controlSubsystem{}, err + } + if err := control.Link(shutdownFile, shutdownFileName); err != nil { + cancel() + return controlSubsystem{}, err + } + return controlSubsystem{ + name: controlFileName, + directory: control, + shutdown: shutdown{ + ChannelFile: shutdownFile, + name: shutdownFileName, + ch: shutdownCh, + cancel: cancel, + }, + }, nil +} + +func linkSystems(system *fileSystem) error { + root := system.root for _, file := range []struct { p9.File name string }{ { - name: mounterName, - File: mounter, + name: system.mount.name, + File: system.mount.MountFile, }, { - name: listenerName, - File: listeners, + name: system.listen.name, + File: system.listen.Listener, + }, + { + name: system.control.name, + File: system.control.directory, }, } { - if name := file.name; name != "" { - if err := fsys.Link(file.File, name); err != nil { - panic(err) - } + if err := root.Link(file.File, file.name); err != nil { + return err } } - return fsys, listeners + return nil } -func isPipe(file *os.File) bool { - fStat, err := file.Stat() - if err != nil { - return false +func logListeners(log ulog.Logger, listeners <-chan manet.Listener) { + for l := range listeners { + log.Printf("listening on: %s\n", l.Multiaddr()) } - return fStat.Mode().Type()&os.ModeNamedPipe != 0 } -func handleStdio(ctx context.Context, server *p9.Server) <-chan error { - errs := make(chan error) - go func() { - defer close(errs) - if err := server.Handle(os.Stdin, os.Stdout); err != nil { - if !errors.Is(err, io.EOF) { - maybeSendErr(ctx, errs, err) +func serveListeners(serveFn serveFunc, listeners <-chan manet.Listener, + errs wgErrs, +) { + defer errs.Done() + var ( + serveWg sync.WaitGroup + serve = func(listener manet.Listener) { + defer serveWg.Done() + err := serveFn(listener) + if err == nil || + // Ignore value caused by server.Shutdown(). + // (daemon closed listener.) + errors.Is(err, p9net.ErrServerClosed) || + // Ignore value caused by listener.Close(). + // (external|fs closed listener.) + errors.Is(err, net.ErrClosed) { return } + err = fmt.Errorf("%w: %s - %w", + errServe, listener.Multiaddr(), err, + ) + errs.send(err) } - if err := os.Stderr.Close(); err != nil { - maybeSendErr(ctx, errs, err) - return + ) + for listener := range listeners { + serveWg.Add(1) + go serve(listener) + } + serveWg.Wait() +} + +func relayUnordered[T any](in <-chan T, outs ...*<-chan T) { + chs := make([]chan<- T, len(outs)) + for i := range outs { + ch := make(chan T, cap(in)) + *outs[i] = ch + chs[i] = ch + } + go relayChan(in, chs...) +} + +// relayChan will relay values (in a non-blocking manner) +// from `source` to all `relays` (immediately or eventually). +// The source must be closed to stop processing. +// Each relay is closed after all values are sent. +// Relay receive order is not guaranteed to match +// source's order. +func relayChan[T any](source <-chan T, relays ...chan<- T) { + var ( + relayValues = reflectSendChans(relays...) + relayCount = len(relayValues) + disabledCase = reflect.Value{} + defaultCase = relayCount + cases = make([]reflect.SelectCase, defaultCase+1) + closerWgs = make([]*sync.WaitGroup, relayCount) + send = func(wg *sync.WaitGroup, ch chan<- T, value T) { + ch <- value + wg.Done() } - if err := reopenNullStdio(); err != nil { - maybeSendErr(ctx, errs, err) + ) + cases[defaultCase] = reflect.SelectCase{Dir: reflect.SelectDefault} + for value := range source { + populateSelectSendCases(value, relayValues, cases) + for remaining := relayCount; remaining != 0; { + chosen, _, _ := reflect.Select(cases) + if chosen != defaultCase { + cases[chosen].Chan = disabledCase + remaining-- + continue + } + for i, commCase := range cases[:relayCount] { + if !commCase.Chan.IsValid() { + continue // Already sent. + } + wg := closerWgs[i] + if wg == nil { + wg = new(sync.WaitGroup) + closerWgs[i] = wg + } + wg.Add(1) + go send(wg, relays[i], value) + } + break } - }() - return errs + } + waitAndClose := func(wg *sync.WaitGroup, ch chan<- T) { + wg.Wait() + close(ch) + } + for i, wg := range closerWgs { + if wg == nil { + close(relays[i]) + continue + } + go waitAndClose(wg, relays[i]) + } } -func reopenNullStdio() error { - const stdioMode = 0o600 - discard, err := os.OpenFile(os.DevNull, os.O_RDWR, stdioMode) - if err != nil { - return err +func reflectSendChans[T any](chans ...chan<- T) []reflect.Value { + values := make([]reflect.Value, len(chans)) + for i, relay := range chans { + values[i] = reflect.ValueOf(relay) } - for _, f := range []**os.File{&os.Stdin, &os.Stdout, &os.Stderr} { - *f = discard + return values +} + +// populateSelectSendCases will create +// send cases containing `value` for +// each channel in `channels`, and assign it +// within `cases`. Panics if len(cases) < len(channels). +func populateSelectSendCases[T any](value T, channels []reflect.Value, cases []reflect.SelectCase) { + rValue := reflect.ValueOf(value) + for i, channel := range channels { + cases[i] = reflect.SelectCase{ + Dir: reflect.SelectSend, + Chan: channel, + Send: rValue, + } } - return nil } -func flattenErrs(errs ...<-chan error) (err error) { - for e := range generic.CtxMerge(context.Background(), errs...) { - if err == nil { - err = e - } else { - err = fmt.Errorf("%w\n%s", err, e) +func sequentialLeveling(stopper <-chan shutdownDisposition, filtered chan<- shutdownDisposition) { + var highestSeen shutdownDisposition + for level := range stopper { + if level > highestSeen { + highestSeen = level + filtered <- level } } - return } -func shutdownWithCounter(ctx context.Context, cancel context.CancelFunc, - counter <-chan uint, listMan *net.ListenerManager, -) <-chan error { +func watchListenersStopper(cancel context.CancelFunc, + stopper <-chan shutdownDisposition, log ulog.Logger, +) { + for range stopper { + log.Print("stop signal received - not accepting new listeners") + cancel() + return + } +} + +func serverStopper(ctx context.Context, + server *p9net.Server, stopper <-chan shutdownDisposition, + errs wgErrs, log ulog.Logger, +) { + defer errs.Done() + const ( + deadline = 10 * time.Second + msgPrefix = "stop signal received - " + connPrefix = "closing connections" + waitMsg = msgPrefix + "closing listeners now" + + " and connections when they're idle" + nowMsg = msgPrefix + connPrefix + " immediately" + waitForConns = patientShutdown + timeoutConns = shortShutdown + closeConns = immediateShutdown + ) var ( - errs = make(chan error) - sawSignal bool + initiated bool + shutdownErr = make(chan error, 1) + sCtx, cancel = context.WithCancel(ctx) ) - go func() { - defer cancel() - const ( - waitForConns = 1 - timeoutConns = 2 - closeConns = 3 - ) - var connectionsCancel context.CancelFunc - for { - select { - case signalCount := <-counter: - // FIXME: timeout+force signals are not canceling properly? - // ^ the context for sure is, but the callsite may be blocking somewhere it shouldn't. - // TODO: Mocking tests for this is going to be annoying, but necessary. - // It may require some API changes for this whole callgraph. - switch signalCount { - case waitForConns: - var connectionsCtx context.Context - sawSignal = true - connectionsCtx, connectionsCancel = context.WithCancel(ctx) - go func() { - defer close(errs) - defer connectionsCancel() - if err := listMan.Shutdown(connectionsCtx); err != nil { - select { - case errs <- err: - case <-ctx.Done(): - } - } - }() - case timeoutConns: - // TODO: Notify clients?: - // mknod `/listeners/shuttingdown` {$time.Time} - go func() { - // TODO: const - <-time.After(10 * time.Second) - connectionsCancel() - }() - case closeConns: - connectionsCancel() - return - } - case <-ctx.Done(): - if !sawSignal { - close(errs) - } + defer cancel() + for { + select { + case level, ok := <-stopper: + if !ok { return } + switch level { + case waitForConns: + log.Print(waitMsg) + case timeoutConns: + time.AfterFunc(deadline, cancel) + log.Printf("%sforcefully %s in %s", + msgPrefix, connPrefix, deadline) + case closeConns: + cancel() + log.Print(nowMsg) + default: + err := fmt.Errorf("unexpected signal: %v", level) + errs.send(err) + continue + } + if !initiated { + initiated = true + go func() { shutdownErr <- server.Shutdown(sCtx) }() + } + case err := <-shutdownErr: + if err != nil && + !errors.Is(err, context.Canceled) { + errs.send(err) + } + return } - }() - return errs + } +} + +func unmountAll(system mountSubsystem, + levels <-chan shutdownDisposition, + errs wgErrs, log ulog.Logger, +) { + defer errs.Done() + <-levels + log.Print("stop signal received - unmounting all") + dir := system.MountFile + if err := p9fs.UnmountAll(dir); err != nil { + errs.send(err) + } +} + +func stopOnDone(ctx context.Context, shutdownSend wgShutdown) { + defer shutdownSend.Done() + select { + case <-ctx.Done(): + shutdownSend.send(immediateShutdown) + case <-shutdownSend.closing: + } +} + +func stopOnUnreachable(fsys *fileSystem, stopper wgShutdown, + errs wgErrs, log ulog.Logger, +) { + const ( + keepRunning = false + stopRunning = true + interval = 10 * time.Minute + idleMessage = "daemon is unreachable and has" + + " no active mounts - unreachable shutdown" + ) + var ( + idleCheckFn = makeIdleChecker(fsys, interval, ulog.Null) + listeners = fsys.listen.Listener + unreachableCheckFn = func() (bool, shutdownDisposition, error) { + shutdown, _, err := idleCheckFn() + if !shutdown || err != nil { + return keepRunning, dontShutdown, err + } + haveNetwork, err := hasEntries(listeners) + if haveNetwork || err != nil { + return keepRunning, dontShutdown, err + } + log.Print(idleMessage) + return stopRunning, immediateShutdown, nil + } + ) + stopWhen(unreachableCheckFn, interval, stopper, errs) } -func shutdownOnIdle(ctx context.Context, interval time.Duration, - fsys p9.File, listMan *net.ListenerManager, -) <-chan error { - errs := make(chan error) +func stopOnShutdownWrite(data <-chan []byte, stopper wgShutdown, + errs wgErrs, log ulog.Logger, +) { + defer errs.Done() + defer stopper.Done() + for { + select { + case data, ok := <-data: + if !ok { + return + } + level, err := parseDispositionData(data) + if err != nil { + errs.send(err) + continue + } + log.Printf(`external source requested to shutdown: "%s"`, level.String()) + if !stopper.send(level) { + return + } + case <-stopper.Closing(): + return + } + } +} + +func parseDispositionData(data []byte) (shutdownDisposition, error) { + if len(data) != 1 { + str := strings.TrimSpace(string(data)) + return generic.ParseEnum(minimumShutdown, maximumShutdown, str) + } + level := shutdownDisposition(data[0]) + if level < minimumShutdown || level > maximumShutdown { + return 0, fmt.Errorf("%w:"+ + "got: %d, valid level range is: %d:%d", + errShutdownDisposition, level, + minimumShutdown, maximumShutdown, + ) + } + return level, nil +} + +func isPipe(file *os.File) bool { + fStat, err := file.Stat() + if err != nil { + return false + } + return fStat.Mode().Type()&os.ModeNamedPipe != 0 +} + +func handleStdio(ctx context.Context, exitCh <-chan bool, + control p9.File, handleFn handleFunc, + wg *sync.WaitGroup, errs wgErrs, +) { + defer func() { wg.Done(); errs.Done() }() + childProcInit() + if exiting := <-exitCh; exiting { + // Process wants to exit. Parent process + return // should be monitoring stderr. + } + var ( + releaseCtx, cancel = context.WithCancel(ctx) + releaseChan, err = addIPCReleaseFile(releaseCtx, control) + ) + if err != nil { + cancel() + errs.send(err) + return + } go func() { - defer close(errs) - _, mounterDir, err := fsys.Walk([]string{mounterName}) - if err != nil { - maybeSendErr(ctx, errs, err) + // NOTE: + // 1) If we receive data, the parent process + // is signaling that it's about to close the + // write end of stderr. We don't validate this + // because we'll be in a detached state. I.e. + // even if we ferry the errors back to execute, + // the write end of stderr is (likely) closed. + // 2) If the parent process doesn't follow + // our IPC protocol, this routine will remain + // active. We don't force the service to wait + // for our return; it's allowed to halt regardless. + select { + case <-releaseChan: + defer cancel() + const flags = 0 + // XXX: [presumption / no guard] + // we assume no os handle access or changes + // will happen during this window. Our only + // writer should be in the return from main, + // and daemon's execute should not be doing + // (other) os file operations at this time. + os.Stderr.Close() + if discard, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0); err == nil { + os.Stderr = discard + } + control.UnlinkAt(ipcReleaseFileName, flags) + case <-releaseCtx.Done(): + } + }() + if err := handleFn(os.Stdin, os.Stdout); err != nil { + if !errors.Is(err, io.EOF) { + errs.send(err) return } - idleCheckTicker := time.NewTicker(interval) - defer idleCheckTicker.Stop() - for { - select { - case <-idleCheckTicker.C: - busy, err := haveMounts(mounterDir) - if err != nil { - maybeSendErr(ctx, errs, err) - return - } - if busy { - continue - } - if err := listMan.Shutdown(ctx); err != nil { - maybeSendErr(ctx, errs, err) - } + // NOTE: handleFn implicitly closes its parameters + // before returning. Otherwise we'd close them. + } +} + +func addIPCReleaseFile(ctx context.Context, control p9.File) (<-chan []byte, error) { + _, releaseFile, releaseChan, err := p9fs.NewChannelFile(ctx) + if err != nil { + return nil, err + } + if err := control.Link(releaseFile, ipcReleaseFileName); err != nil { + return nil, err + } + return releaseChan, nil +} + +func stopWhen(checkFn checkFunc, interval time.Duration, + stopper wgShutdown, + errs wgErrs, +) { + defer func() { + errs.Done() + stopper.Done() + }() + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + stop, level, err := checkFn() + if err != nil { + errs.send(err) return - case <-ctx.Done(): + } + if stop { + stopper.send(level) return } + case <-stopper.Closing(): + return } - }() - return errs + } +} + +// makeIdleChecker prevents the process from lingering around +// if a client closes all services, then disconnects. +func makeIdleChecker(fsys *fileSystem, interval time.Duration, log ulog.Logger) checkFunc { + var ( + mounts = fsys.mount.MountFile + listeners = fsys.listen.Listener + ) + const ( + keepRunning = false + stopRunning = true + idleMessage = "daemon has no active mounts or connections" + + " - idle shutdown" + ) + return func() (bool, shutdownDisposition, error) { + mounted, err := hasEntries(mounts) + if mounted || err != nil { + return keepRunning, dontShutdown, err + } + activeConns, err := hasActiveClients(listeners, interval) + if activeConns || err != nil { + return keepRunning, dontShutdown, err + } + log.Print(idleMessage) + return stopRunning, immediateShutdown, nil + } } -func haveMounts(mounterDir p9.File) (bool, error) { - ents, err := files.ReadDir(mounterDir) +func hasEntries(fsys p9.File) (bool, error) { + ents, err := p9fs.ReadDir(fsys) if err != nil { return false, err } return len(ents) > 0, nil } -func maybeSendErr(ctx context.Context, errs chan<- error, err error) { - select { - case errs <- err: - case <-ctx.Done(): +func hasActiveClients(listeners p9.File, threshold time.Duration) (bool, error) { + infos, err := p9fs.GetConnections(listeners) + if err != nil { + return false, err + } + for _, info := range infos { + lastActive := lastActive(&info) + if time.Since(lastActive) <= threshold { + return true, nil + } + } + return false, nil +} + +func lastActive(info *p9fs.ConnInfo) time.Time { + var ( + read = info.LastRead + write = info.LastWrite + ) + if read.After(write) { + return read } + return write } diff --git a/internal/commands/daemon_unix.go b/internal/commands/daemon_unix.go new file mode 100644 index 00000000..db8e9eae --- /dev/null +++ b/internal/commands/daemon_unix.go @@ -0,0 +1,35 @@ +//go:build unix + +package commands + +import ( + "context" + "os" + "os/signal" + "syscall" +) + +func registerSystemStoppers(_ context.Context, shutdownSend wgShutdown) { + shutdownSend.Add(2) + go stopOnSignalLinear(shutdownSend, os.Interrupt) + go stopOnSignalLinear(shutdownSend, syscall.SIGTERM) +} + +func stopOnSignalLinear(stopCh wgShutdown, sig os.Signal) { + signals := make(chan os.Signal, 1) + signal.Notify(signals, sig) + defer func() { + signal.Stop(signals) + stopCh.Done() + }() + for count := minimumShutdown; count <= maximumShutdown; count++ { + select { + case <-signals: + if !stopCh.send(count) { + return + } + case <-stopCh.Closing(): + return + } + } +} diff --git a/internal/commands/daemon_windows.go b/internal/commands/daemon_windows.go new file mode 100644 index 00000000..4c32a605 --- /dev/null +++ b/internal/commands/daemon_windows.go @@ -0,0 +1,217 @@ +package commands + +import ( + "context" + "fmt" + "os" + "os/signal" + "runtime" + "syscall" + "unsafe" + + "github.com/djdv/go-filesystem-utils/internal/generic" + "github.com/lxn/win" + "golang.org/x/sys/windows" +) + +// NOTE: On signal handling. +// Go's default signal handler translates some messages +// into `SIGTERM` (see [os/signal] documentation). +// Windows has at least 3 forms of messaging that apply to us. +// 1) Control signals. +// 2) Window messages. +// 3) Service events. +// 1 applies to SIGTERM, with exceptions. +// `HandlerRoutine` will not receive `CTRL_LOGOFF_EVENT` nor +// `CTRL_SHUTDOWN_EVENT` for "interactive applications" +// (applications which link with `user32`; +// see `SetConsoleCtrlHandler` documentation). +// +// We utilize `user32` (indirectly through the [xdg] package) +// which flags us as interactive. As such, we need to +// initialize a window message queue (2) and monitor it for +// these events. The console handler (1) can (and must) be +// registered simultaneously, to handle the other signals +// such as interrupt, break, close, etc. +// +// We do not yet handle case 3. + +type wndProcFunc func(win.HWND, uint32, uintptr, uintptr) uintptr + +func registerSystemStoppers(ctx context.Context, shutdownSend wgShutdown) { + shutdownSend.Add(2) + // NOTE: [Go 1.20] This must be `syscall.SIGTERM` + // not `windows.SIGTERM`, otherwise the runtime + // will not set up the console control handler. + go stopOnSignalLinear(shutdownSend, syscall.SIGTERM) + go stopOnSignalLinear(shutdownSend, os.Interrupt) + if err := createAndWatchWindow(ctx, shutdownSend); err != nil { + panic(err) + } +} + +func stopOnSignalLinear(shutdownSend wgShutdown, sig os.Signal) { + signals := make(chan os.Signal, 1) + signal.Notify(signals, sig) + defer func() { + signal.Stop(signals) + shutdownSend.Done() + }() + for count := minimumShutdown; count <= maximumShutdown; count++ { + select { + case <-signals: + if !shutdownSend.send(count) { + return + } + case <-shutdownSend.Closing(): + return + } + } +} + +func createAndWatchWindow(ctx context.Context, shutdownSend wgShutdown) error { + errs := make(chan error, 1) + go func() { + defer shutdownSend.Done() + runtime.LockOSThread() // The window and message processor + defer runtime.UnlockOSThread() // must be on the same thread. + hWnd, err := createEventWindow("go-fs", shutdownSend) + errs <- err + if err != nil { + return + } + closeWindowWhenDone := func() { + select { + case <-ctx.Done(): + case <-shutdownSend.Closing(): + } + const ( + NULL = 0 + wParam = NULL + lParam = NULL + ) + win.SendMessage(hWnd, win.WM_CLOSE, wParam, lParam) + } + go closeWindowWhenDone() + const ( + // Ignore C's `BOOL` declaration for `GetMessage` + // it actually returns a trinary value. See MSDN docs. + failed = -1 + wmQuit = 0 + success = 1 + NULL = 0 + msgFilterMin = NULL + msgFilterMax = NULL + ) + for { + var msg win.MSG + switch win.GetMessage(&msg, hWnd, msgFilterMin, msgFilterMax) { + case failed, wmQuit: + // NOTE: If we fail here the error + // (`GetLastError`) is dropped. + // Given our parameter set, failure + // implies the window handle was (somehow) + // invalidated, so we can't continue. + // This is very unlikely to happen on accident. + // Especially outside of development. + return + case success: + win.TranslateMessage(&msg) + win.DispatchMessage(&msg) + } + } + }() + return <-errs +} + +func createEventWindow(name string, shutdownSend wgShutdown) (win.HWND, error) { + const INVALID_HANDLE_VALUE win.HWND = ^win.HWND(0) + lpClassName, err := windows.UTF16PtrFromString(name) + if err != nil { + return INVALID_HANDLE_VALUE, err + } + var ( + hInstance = win.GetModuleHandle(nil) + windowClass = win.WNDCLASSEX{ + LpfnWndProc: newWndProc(shutdownSend), + HInstance: hInstance, + LpszClassName: lpClassName, + } + ) + windowClass.CbSize = uint32(unsafe.Sizeof(windowClass)) + _ = win.RegisterClassEx(&windowClass) + const ( + NULL = 0 + dwExStyle = NULL + dwStyle = NULL + x = NULL + y = NULL + nWidth = NULL + nHeight = NULL + hWndParent = NULL + hMenu = NULL + ) + var ( + lpWindowName *uint16 = nil + lpParam unsafe.Pointer = nil + hWnd = win.CreateWindowEx( + dwExStyle, + lpClassName, lpWindowName, + dwStyle, + x, y, + nWidth, nHeight, + hWndParent, hMenu, + hInstance, lpParam, + ) + ) + if hWnd == NULL { + var err error = generic.ConstError( + "CreateWindowEx failed", + ) + if lErr := windows.GetLastError(); lErr != nil { + err = fmt.Errorf("%w: %w", err, lErr) + } + return INVALID_HANDLE_VALUE, err + } + return hWnd, nil +} + +func newWndProc(shutdownSend wgShutdown) uintptr { + return windows.NewCallback( + func(hWnd win.HWND, uMsg uint32, wParam uintptr, lParam uintptr) uintptr { + switch uMsg { + case win.WM_QUERYENDSESSION: + const shutdownOrRestart = 0 + var disposition shutdownDisposition + switch { + case lParam == shutdownOrRestart: + disposition = immediateShutdown + case lParam&win.ENDSESSION_LOGOFF != 0: + disposition = shortShutdown + case lParam&win.ENDSESSION_CRITICAL != 0: + disposition = immediateShutdown + default: + disposition = immediateShutdown + } + shutdownSend.send(disposition) + const ( + FALSE = 0 + canClose = FALSE + ) + return canClose + case win.WM_CLOSE: + const processedToken = 0 + shutdownSend.send(immediateShutdown) + win.DestroyWindow(hWnd) + return processedToken + case win.WM_DESTROY: + const processedToken = 0 + shutdownSend.send(immediateShutdown) + const toCallingThread = 0 + win.PostQuitMessage(toCallingThread) + return processedToken + default: + return win.DefWindowProc(hWnd, uMsg, wParam, lParam) + } + }) +} diff --git a/internal/commands/doc.go b/internal/commands/doc.go new file mode 100644 index 00000000..5eb648d1 --- /dev/null +++ b/internal/commands/doc.go @@ -0,0 +1,2 @@ +// Package commands implements command constructors for the main command. +package commands diff --git a/internal/commands/flag.go b/internal/commands/flag.go index 17749154..796685c8 100644 --- a/internal/commands/flag.go +++ b/internal/commands/flag.go @@ -1,250 +1,581 @@ package commands import ( - "errors" + "encoding/csv" "flag" "fmt" + "io/fs" + "math" "os" - "path/filepath" + "reflect" "strconv" "strings" + "time" + "unicode/utf8" + "unsafe" - "github.com/djdv/go-filesystem-utils/internal/command" - "github.com/djdv/go-filesystem-utils/internal/daemon" - "github.com/djdv/go-filesystem-utils/internal/filesystem" - "github.com/hugelgupf/p9/p9" - giconfig "github.com/ipfs/kubo/config" + "github.com/djdv/go-filesystem-utils/internal/generic" + "github.com/djdv/p9/p9" "github.com/multiformats/go-multiaddr" ) type ( - commonSettings struct { - command.HelpArg - verbose bool + optionsReference[ + OS optionSlice[OT, T], + OT generic.OptionFunc[T], + T any, + ] interface { + *OS } - clientSettings struct { - serviceMaddr multiaddr.Multiaddr - commonSettings + optionSlice[ + OT generic.OptionFunc[T], + T any, + ] interface { + ~[]OT } - valueContainer[t any] struct { - tPtr *t - parse func(string) (t, error) - } - lazyFlag[T any] interface{ get() (T, error) } - defaultServerMaddr struct{ multiaddr.Multiaddr } - defaultIPFSMaddr struct{ multiaddr.Multiaddr } + // standard [flag.funcValue] extended + // for [command.ValueNamer]. + // (Because standard uses internal types + // in a way we can't access; + // see: [flag.UnquoteUsage]'s implementation.) + genericFuncValue[T any] func(string) error ) -func (defaultServerMaddr) get() (multiaddr.Multiaddr, error) { - userMaddrs, err := daemon.UserServiceMaddrs() - if err != nil { - return nil, err +func (gf genericFuncValue[T]) Set(s string) error { return gf(s) } +func (gf genericFuncValue[T]) String() string { return "" } +func (gf genericFuncValue[T]) Name() string { + name := reflect.TypeOf((*T)(nil)).Elem().String() + if index := strings.LastIndexByte(name, '.'); index != -1 { + name = name[index+1:] // Remove [QualifiedIdent] prefix. } - return userMaddrs[0], nil + return strings.ToLower(name) } -func (ds defaultServerMaddr) String() string { - maddr, err := ds.get() - if err != nil { - return "" - } - return maddr.String() +const ( + permMaximum = 0o7777 + permReadAll = 0o444 + permWriteAll = 0o222 + permExecuteAll = 0o111 + permUserBits = os.ModeSticky | os.ModeSetuid | 0o700 + permGroupBits = os.ModeSetgid | 0o070 + permOtherBits = 0o007 + permSetid = fs.ModeSetuid | fs.ModeSetgid + permAllBits = permUserBits | permGroupBits | permOtherBits + permOpAdd = '+' + permOpSub = '-' + permOpSet = '=' + permWhoUser = 'u' + permWhoGroup = 'g' + permWhoOther = 'o' + permWhoAll = 'a' + permSymRead = 'r' + permSymWrite = 'w' + permSymExecute = 'x' + permSymSearch = 'X' + permSymSetID = 's' + permSymText = 't' +) + +func makeWithOptions[OT generic.OptionFunc[T], T any](options ...OT) (T, error) { + var settings T + return settings, generic.ApplyOptions(&settings, options...) } -func (defaultIPFSMaddr) get() (multiaddr.Multiaddr, error) { - maddrs, err := getIPFSAPI() +func parseID[id fuseID | p9.UID | p9.GID](arg string) (id, error) { + const nobody = "nobody" + if arg == nobody { + var value id + switch any(value).(type) { + case p9.UID: + value = id(p9.NoUID) + case p9.GID: + value = id(p9.NoGID) + case fuseID: + value = id(math.MaxInt32) + } + return value, nil + } + const idSize = 32 + num, err := strconv.ParseUint(arg, 0, idSize) if err != nil { - return nil, err + return 0, err } - return maddrs[0], nil + return id(num), nil } -func (di defaultIPFSMaddr) String() string { - maddr, err := di.get() - if err != nil { - return "no IPFS API file found (must provide this argument)" +func idString[id uint32 | p9.UID | p9.GID](who id) string { + const nobody = "nobody" + switch typed := any(who).(type) { + case p9.UID: + if typed == p9.NoUID { + return nobody + } + case p9.GID: + if typed == p9.NoGID { + return nobody + } } - return maddr.String() + return strconv.Itoa(int(who)) } -func (set *commonSettings) BindFlags(fs *flag.FlagSet) { - set.HelpArg.BindFlags(fs) - fs.BoolVar(&set.verbose, "verbose", - false, "Enable log messages.") +func parseShutdownLevel(level string) (shutdownDisposition, error) { + return generic.ParseEnum(minimumShutdown, maximumShutdown, level) } -func (set *clientSettings) BindFlags(fs *flag.FlagSet) { - set.commonSettings.BindFlags(fs) - multiaddrVar(fs, &set.serviceMaddr, daemon.ServerName, - defaultServerMaddr{}, "File system service `maddr`.") +// parsePOSIXPermissions accepts a `chmod` "mode" parameter +// (as defined in SUSv4;BSi7), and returns the result of +// applying it to the `mode` value. +func parsePOSIXPermissions(mode fs.FileMode, clauses string) (fs.FileMode, error) { + // NOTE: The POSIX specification uses ASCII, + // and so does the current version of this parser. + // As a result, Unicode digits for octals and + // any alternate symbol forms - are not supported. + const ( + base = 8 + bits = int(unsafe.Sizeof(fs.FileMode(0))) * base + ) + if value, err := strconv.ParseUint(clauses, base, bits); err == nil { + if value > permMaximum { + return 0, fmt.Errorf(`%w: "%s" exceeds permission bits boundary (%o)`, + strconv.ErrSyntax, clauses, permMaximum) + } + return parseOctalPermissions(mode, fs.FileMode(value)), nil + } + return evalPermissionClauses( + mode, + parseOctalPermissions(0, getUmask()), + strings.Split(clauses, ","), + ) } -func containerVar[t any, parser func(string) (t, error)](fs *flag.FlagSet, tPtr *t, - name string, defVal t, usage string, - parse parser, -) { - *tPtr = defVal - fs.Var(valueContainer[t]{ - tPtr: tPtr, - parse: parse, - }, name, usage) -} - -func (vc valueContainer[T]) String() string { - tPtr := vc.tPtr - if tPtr == nil { - return "" - } - tVal := *tPtr - // XXX: Special cases. - // TODO Handle this better. Optional helptext string in the constructor? - const invalidID = "nobody" - switch valType := any(tVal).(type) { - case p9.UID: - if !valType.Ok() { - return invalidID - } - case p9.GID: - if !valType.Ok() { - return invalidID +func parseOctalPermissions(mode, operand fs.FileMode) fs.FileMode { + const ( + posixSuid = 0o4000 + posixSgid = 0o2000 + posixText = 0o1000 + ) + var ( + explicitHighBits bool + permissions = operand.Perm() + ) + for _, pair := range [...]struct { + posix, golang fs.FileMode + }{ + { + posix: posixSuid, + golang: fs.ModeSetuid, + }, + { + posix: posixSgid, + golang: fs.ModeSetgid, + }, + { + posix: posixText, + golang: fs.ModeSticky, + }, + } { + if operand&pair.posix != 0 { + permissions |= pair.golang + explicitHighBits = true } } - // Regular. - return fmt.Sprint(tVal) + // SUSv4;BSi7 Extended description; + // sentence directly preceding octal table. + if mode.IsDir() && !explicitHighBits { + permissions |= mode & (permSetid | fs.ModeSticky) + } + return mode.Type() | permissions } -func (vc valueContainer[t]) Set(arg string) error { - tVal, err := vc.parse(arg) - if err != nil { - return err - } - if vc.tPtr == nil { - vc.tPtr = new(t) +func evalPermissionClauses(mode, umask fs.FileMode, clauses []string) (fs.FileMode, error) { + for _, clause := range clauses { + if clause == "" { + return 0, generic.ConstError("empty clause") + } + remainder, impliedAll, whoMask := parseWho(clause) + for len(remainder) != 0 { + var ( + op rune + permissions fs.FileMode + err error + ) + remainder, op, permissions, err = evalOp(remainder, mode) + if err != nil { + return 0, err + } + mode = applyOp( + impliedAll, whoMask, + mode, permissions, + umask, op, + ) + } } - *vc.tPtr = tVal - return nil + return mode, nil } -func multiaddrVar(fs *flag.FlagSet, maddrPtr *multiaddr.Multiaddr, - name string, defVal multiaddr.Multiaddr, usage string, -) { - containerVar(fs, maddrPtr, name, defVal, usage, multiaddr.NewMultiaddr) +func parseWho(clause string) (string, bool, fs.FileMode) { + var ( + index int + mask fs.FileMode + ) +out: + for _, who := range clause { + switch who { + case permWhoUser: + mask |= permUserBits + case permWhoGroup: + mask |= permGroupBits + case permWhoOther: + mask |= permOtherBits + case permWhoAll: + mask = permAllBits + default: + break out + } + index++ + } + // Distinguish between explicit and implied "all". + // SUSv4;BSi7 - Operation '=', sentence 3-4. + var impliedAll bool + if mask == 0 { + impliedAll = true + mask = permAllBits + } + return clause[index:], impliedAll, mask } -func parseID[id p9.UID | p9.GID](arg string) (id, error) { - const idSize = 32 - num, err := strconv.ParseUint(arg, 0, idSize) +func evalOp(operation string, mode fs.FileMode) (string, rune, fs.FileMode, error) { + op, operand, err := parseOp(operation) if err != nil { - return 0, err + return "", 0, 0, err } - return id(num), nil + remainder, permissions, err := parsePermissions(mode, operand) + return remainder, op, permissions, err } -func uidVar(fs *flag.FlagSet, uidPtr *p9.UID, - name string, defVal p9.UID, usage string, -) { - containerVar(fs, uidPtr, name, defVal, usage, parseID[p9.UID]) +func parseOp(clauseOp string) (rune, string, error) { + switch op, _ := utf8.DecodeRuneInString(clauseOp); op { + case permOpAdd, permOpSub, permOpSet: + const opOffset = 1 // WARN: ASCII-ism. + return op, clauseOp[opOffset:], nil + default: + return 0, "", fmt.Errorf("missing op symbol, got: %c", op) + } } -func gidVar(fs *flag.FlagSet, gidPtr *p9.GID, - name string, defVal p9.GID, usage string, -) { - containerVar(fs, gidPtr, name, defVal, usage, parseID[p9.GID]) +func parsePermissions(mode fs.FileMode, clauseOperand string) (string, fs.FileMode, error) { + remainder, copied, permissions := parsePermcopy(mode, clauseOperand) + if copied { + return remainder, permissions, nil + } + var ( + index int + bits os.FileMode + ) + for _, perm := range clauseOperand { + switch perm { + case permSymRead: + bits |= permReadAll + case permSymWrite: + bits |= permWriteAll + case permSymExecute: + bits |= permExecuteAll + case permSymSearch: + // SUSv4;BSi7 Extended description paragraph 5. + if mode.IsDir() || + (mode&permExecuteAll != 0) { + bits |= permExecuteAll + } + case permSymSetID: + bits |= permSetid + case permSymText: + bits |= fs.ModeSticky + case permOpAdd, permOpSub, permOpSet: + return clauseOperand[index:], bits, nil + default: + return "", 0, fmt.Errorf("unexpected perm symbol: %c", perm) + } + index++ + } + return clauseOperand[index:], bits, nil } -func fsIDVar(fs *flag.FlagSet, fsidPtr *filesystem.ID, - name string, defVal filesystem.ID, usage string, -) { - containerVar(fs, fsidPtr, name, defVal, usage, filesystem.ParseID) +func parsePermcopy(mode fs.FileMode, clauseFragment string) (string, bool, fs.FileMode) { + if len(clauseFragment) == 0 { + return "", false, 0 + } + const ( + groupShift = 3 + userShift = 6 + ) + var permissions fs.FileMode + switch who, _ := utf8.DecodeRuneInString(clauseFragment); who { + case permWhoUser: + permissions = (mode & permUserBits) >> userShift + case permWhoGroup: + permissions = (mode & permGroupBits) >> groupShift + case permWhoOther: + permissions = (mode & permOtherBits) + default: + return "", false, 0 + } + // Replicate the permission bits to all fields. + // (Caller is expected to mask against "who".) + permissions |= (permissions << groupShift) | + (permissions << userShift) + return clauseFragment[1:], true, permissions } -func fsAPIVar(fs *flag.FlagSet, fsAPIPtr *filesystem.API, - name string, defVal filesystem.API, usage string, -) { - containerVar(fs, fsAPIPtr, name, defVal, usage, filesystem.ParseAPI) +func applyOp(impliedAll bool, + who, mode, mask, umask fs.FileMode, op rune, +) fs.FileMode { + mask &= who + if impliedAll { + mask &^= umask + } + switch op { + case '+': + mode |= mask + case '-': + mode &^= mask + case '=': + // SUSv4;BSi7 says set-*-ID bit handling for non-regular files + // is implementation-defined. + // Most unices seem to preserve set-*-ID bits for directories. + if mode.IsDir() { + mask |= (mode & permSetid) + } + mode = (mode &^ who) | mask + } + return mode } -func getIPFSAPI() ([]multiaddr.Multiaddr, error) { - location, err := getIPFSAPIPath() - if err != nil { - return nil, err - } - if !apiFileExists(location) { - return nil, errors.New("IPFS API file not found") // TODO: proper error value +func modeFromFS(mode fs.FileMode) p9.FileMode { + const ( + linuxSuid = 0o4000 + linuxSgid = 0o2000 + ) + // NOTE: [2023.05.20] + // Upstream library drops bits `0o7000` + // in this call. Since we (currently) use + // 9P2000.L and these bits are valid, we add + // them back in if present. + mode9 := p9.ModeFromOS(mode) + for _, pair := range [...]struct { + plan9 p9.FileMode + golang fs.FileMode + }{ + { + plan9: linuxSuid, + golang: fs.ModeSetuid, + }, + { + plan9: linuxSgid, + golang: fs.ModeSetgid, + }, + { + plan9: p9.Sticky, + golang: fs.ModeSticky, + }, + } { + if mode&pair.golang != 0 { + mode9 |= pair.plan9 + } } - return parseIPFSAPI(location) + return mode9 } -func getIPFSAPIPath() (string, error) { - const apiFile = "api" - var target string - if ipfsPath, set := os.LookupEnv(giconfig.EnvDir); set { - target = filepath.Join(ipfsPath, apiFile) - } else { - target = filepath.Join(giconfig.DefaultPathRoot, apiFile) +func modeToSymbolicPermissions(mode fs.FileMode) string { + const ( + prefix = 2 // u= + maxCell = 4 // rwxs + separator = 1 // , + groups = 3 // u,g,o + maxSize = ((prefix + maxCell) * groups) + (separator * (groups - 1)) + ) + var ( + sb strings.Builder + pairs = []struct { + whoMask, specMask fs.FileMode + whoSymbol, specSymbol rune + }{ + { + whoMask: permUserBits, + whoSymbol: permWhoUser, + specMask: fs.ModeSetuid, + specSymbol: permSymSetID, + }, + { + whoMask: permGroupBits, + whoSymbol: permWhoGroup, + specMask: fs.ModeSetgid, + specSymbol: permSymSetID, + }, + { + whoMask: permOtherBits, + whoSymbol: permWhoOther, + specMask: fs.ModeSticky, + specSymbol: permSymText, + }, + } + ) + sb.Grow(maxSize) + var previousLen int + for i, pair := range pairs { + writePermSymbols(&sb, mode, pair.whoMask, pair.specMask, pair.whoSymbol, pair.specSymbol) + if i != len(pairs)-1 && sb.Len() != previousLen { + sb.WriteByte(',') + } + previousLen = sb.Len() // No writes, no separator. } - return expandHomeShorthand(target) + return sb.String() } -func expandHomeShorthand(name string) (string, error) { - if !strings.HasPrefix(name, "~") { - return name, nil +func writePermSymbols(sb *strings.Builder, mode, who, special fs.FileMode, whoSym, specSym rune) { + var ( + filtered = mode & who + haveSpecial = mode&special != 0 + runes []rune + pairs = []struct { + mask fs.FileMode + symbol rune + }{ + { + mask: permReadAll, + symbol: permSymRead, + }, + { + mask: permWriteAll, + symbol: permSymWrite, + }, + { + mask: permExecuteAll, + symbol: permSymExecute, + }, + } + ) + for _, pair := range pairs { + if filtered&pair.mask != 0 { + runes = append(runes, pair.symbol) + } } - homeName, err := os.UserHomeDir() - if err != nil { - return "", err + if len(runes) == 0 && !haveSpecial { + return + } + sb.WriteRune(whoSym) + sb.WriteByte('=') + for _, r := range runes { + sb.WriteRune(r) + } + if haveSpecial { + sb.WriteRune(specSym) } - return filepath.Join(homeName, name[1:]), nil } -func apiFileExists(name string) bool { - _, err := os.Stat(name) - return err == nil +func header(text string) string { + return "# " + text } -func parseIPFSAPI(name string) ([]multiaddr.Multiaddr, error) { - // NOTE: [upstream problem] - // If the config file has multiple API maddrs defined, - // only the first one will be contained in the API file. - maddrString, err := os.ReadFile(name) - if err != nil { - return nil, err +func underline(text string) string { + return fmt.Sprintf( + "%s\n%s", + text, + strings.Repeat("-", len(text)), + ) +} + +func flagSetFunc[ + OSR optionsReference[OS, OT, ST], + OS optionSlice[OT, ST], + OT generic.OptionFunc[ST], + setterFn func(VT, *ST) error, + ST, VT any, +](flagSet *flag.FlagSet, name, usage string, + options OSR, setter setterFn, +) { + // `bool` flags don't require a value and this + // must be conveyed to the [flag] package. + if _, ok := any(setter).(func(bool, *ST) error); ok { + boolFunc(flagSet, name, usage, func(parameter string) error { + return parseAndSet(parameter, options, setter) + }) + return } - maddr, err := multiaddr.NewMultiaddr(string(maddrString)) + funcFlag[VT](flagSet, name, usage, func(parameter string) error { + return parseAndSet(parameter, options, setter) + }) +} + +func funcFlag[T any](flagSet *flag.FlagSet, name, usage string, fn func(string) error) { + flagSet.Var(genericFuncValue[T](fn), name, usage) +} + +func parseAndSet[ + OSR optionsReference[OS, OT, ST], + OS optionSlice[OT, ST], + OT generic.OptionFunc[ST], + setterFn func(VT, *ST) error, + ST, VT any, +](parameter string, options OSR, setter setterFn, +) error { + value, err := parseFlag[VT](parameter) if err != nil { - return nil, err + return err } - return []multiaddr.Multiaddr{maddr}, nil + *options = append(*options, func(settings *ST) error { + return setter(value, settings) + }) + return nil } -/* TODO: [lint] we might still want to use this method instead. Needs consideration. -func ipfsAPIFromConfig([]multiaddr.Multiaddr, error) { - // TODO: We don't need, nor want the full config. - // - IPFS doesn't declare a standard environment variable to use for the API. - // We should declare and document our own to avoid touching the fs at all. - // - The API file format is unlikely to change, we should probably just parse it by hand. - // (The full config file contains node secrets - // and I really don't want to pull those into memory at all.) - // ^ We should try to coordinate upstream. Something this common should really be standardized. - confFile, err := giconfig.Filename("", "") - if err != nil { - return nil, err +func parseFlag[V any](parameter string) (value V, err error) { + switch typed := any(&value).(type) { + case *string: + *typed = parameter + case *bool: + *typed, err = strconv.ParseBool(parameter) + case *time.Duration: + *typed, err = time.ParseDuration(parameter) + case *[]multiaddr.Multiaddr: + *typed, err = parseMultiaddrList(parameter) + case *multiaddr.Multiaddr: + *typed, err = multiaddr.NewMultiaddr(parameter) + case *shutdownDisposition: + *typed, err = parseShutdownLevel(parameter) + case *int: + *typed, err = strconv.Atoi(parameter) + case *fuseID: + *typed, err = parseID[fuseID](parameter) + case *p9.UID: + *typed, err = parseID[p9.UID](parameter) + case *p9.GID: + *typed, err = parseID[p9.GID](parameter) + case *uint32: + var temp uint64 + temp, err = strconv.ParseUint(parameter, 0, 32) + *typed = uint32(temp) + default: + err = fmt.Errorf("parser: unexpected type: %T", value) } - nodeConf, err := giconfigfile.Load(confFile) + return +} + +func parseMultiaddrList(parameter string) ([]multiaddr.Multiaddr, error) { + var ( + reader = strings.NewReader(parameter) + csvReader = csv.NewReader(reader) + maddrStrings, err = csvReader.Read() + ) if err != nil { return nil, err } - var ( - apiMaddrStrings = nodeConf.Addresses.API - apiMaddrs = make([]multiaddr.Multiaddr, len(apiMaddrStrings)) - ) - for i, maddrString := range apiMaddrStrings { + maddrs := make([]multiaddr.Multiaddr, 0, len(maddrStrings)) + for _, maddrString := range maddrStrings { maddr, err := multiaddr.NewMultiaddr(maddrString) if err != nil { return nil, err } - apiMaddrs[i] = maddr + maddrs = append(maddrs, maddr) } - return apiMaddrs, nil + return maddrs, nil } -*/ diff --git a/internal/commands/flag_20.go b/internal/commands/flag_20.go new file mode 100644 index 00000000..ee1b2b8a --- /dev/null +++ b/internal/commands/flag_20.go @@ -0,0 +1,17 @@ +//go:build !go1.21 + +package commands + +import "flag" + +type boolFuncValue func(string) error + +func (f boolFuncValue) Set(s string) error { return f(s) } + +func (f boolFuncValue) String() string { return "" } + +func (f boolFuncValue) IsBoolFlag() bool { return true } + +func boolFunc(flagSet *flag.FlagSet, name, usage string, fn func(string) error) { + flagSet.Var(boolFuncValue(fn), name, usage) +} diff --git a/internal/commands/flag_21.go b/internal/commands/flag_21.go new file mode 100644 index 00000000..cdc975fc --- /dev/null +++ b/internal/commands/flag_21.go @@ -0,0 +1,9 @@ +//go:build go1.21 + +package commands + +import "flag" + +func boolFunc(flagSet *flag.FlagSet, name, usage string, fn func(string) error) { + flagSet.BoolFunc(name, usage, fn) +} diff --git a/internal/commands/flag_other.go b/internal/commands/flag_other.go new file mode 100644 index 00000000..72d9efe4 --- /dev/null +++ b/internal/commands/flag_other.go @@ -0,0 +1,7 @@ +//go:build !unix + +package commands + +import "io/fs" + +func getUmask() fs.FileMode { return 0 } diff --git a/internal/commands/flag_unix.go b/internal/commands/flag_unix.go new file mode 100644 index 00000000..dc18a517 --- /dev/null +++ b/internal/commands/flag_unix.go @@ -0,0 +1,14 @@ +//go:build unix + +package commands + +import ( + "io/fs" + "syscall" +) + +func getUmask() fs.FileMode { + mask := syscall.Umask(0) + syscall.Umask(mask) + return fs.FileMode(mask) +} diff --git a/internal/commands/mount.go b/internal/commands/mount.go index d48e5ad4..36eafe36 100644 --- a/internal/commands/mount.go +++ b/internal/commands/mount.go @@ -2,190 +2,424 @@ package commands import ( "context" + "encoding/json" + "errors" "flag" "fmt" - "log" - "os" + "io/fs" "strings" "github.com/djdv/go-filesystem-utils/internal/command" - "github.com/djdv/go-filesystem-utils/internal/daemon" "github.com/djdv/go-filesystem-utils/internal/filesystem" - "github.com/multiformats/go-multiaddr" + p9fs "github.com/djdv/go-filesystem-utils/internal/filesystem/9p" + "github.com/djdv/go-filesystem-utils/internal/generic" + "github.com/djdv/p9/p9" + "github.com/jaevor/go-nanoid" ) type ( - mountSettings struct{ commonSettings } - mountFuseSettings struct{ commonSettings } - mountIPFSSettings struct { - ipfsAPI multiaddr.Multiaddr + marshaller interface { + marshal(argument string) ([]byte, error) + } + mountCmdConstraint[T any, M marshaller] interface { + *T + command.FlagBinder + make() (M, error) + } + mountCmdHost[T any, M marshaller] interface { + mountCmdConstraint[T, M] + usage(filesystem.ID) string + } + mountCmdGuest[ + T any, + M marshaller, + ] interface { + mountCmdConstraint[T, M] + usage(filesystem.Host) string + } + mountSettings struct { + permissions p9.FileMode + uid p9.UID + gid p9.GID + } + mountCmdSettings[ + HM, GM marshaller, + ] struct { clientSettings + host HM + guest GM + apiOptions []MountOption } + mountCmdOption[ + // Host/Guest marshaller constructor types. + // (Typically a slice of functional options.) + HT, GT any, + // Result type of the constructors. + // (Typically a struct with options applied.) + HM, GM marshaller, + // Constraints on *{H,G}T. + // (Needs to satisfy requirements of the `mount` command.) + HC mountCmdHost[HT, HM], + GC mountCmdGuest[GT, GM], + ] func(*mountCmdSettings[HM, GM]) error + mountCmdOptions[ + HT, GT any, + HM, GM marshaller, + HC mountCmdHost[HT, HM], + GC mountCmdGuest[GT, GM], + ] []mountCmdOption[HT, GT, HM, GM, HC, GC] + MountOption func(*mountSettings) error ) -// TODO: move; should be in shared or even in [command] pkg. -func subonlyExec[settings command.Settings[T], cmd command.ExecuteFuncArgs[settings, T], T any]() cmd { - return func(context.Context, settings, ...string) error { - // This command only holds subcommands - // and has no functionality on its own. - return command.ErrUsage +const ( + mountAPIPermissionsDefault = p9fs.ReadUser | p9fs.WriteUser | p9fs.ExecuteUser | + p9fs.ReadGroup | p9fs.ExecuteGroup | + p9fs.ReadOther | p9fs.ExecuteOther +) + +func WithPermissions(permissions p9.FileMode) MountOption { + return func(ms *mountSettings) error { + ms.permissions = permissions + return nil } } -func (set *mountSettings) BindFlags(fs *flag.FlagSet) { - set.commonSettings.BindFlags(fs) +func WithUID(uid p9.UID) MountOption { + return func(ms *mountSettings) error { + ms.uid = uid + return nil + } } -func (set *mountFuseSettings) BindFlags(fs *flag.FlagSet) { - set.commonSettings.BindFlags(fs) +func WithGID(gid p9.GID) MountOption { + return func(ms *mountSettings) error { + ms.gid = gid + return nil + } } -func (set *mountIPFSSettings) BindFlags(fs *flag.FlagSet) { - set.clientSettings.BindFlags(fs) - // TODO: this should be a string, not parsed client-side - // (server may have different namespaces registered + double parse; - // just passthrough argv[x] as-is) - multiaddrVar(fs, &set.ipfsAPI, "ipfs", - defaultIPFSMaddr{}, "IPFS API node `maddr`.") +func (mo *mountCmdOptions[HT, GT, HM, GM, HC, GC]) BindFlags(flagSet *flag.FlagSet) { + type cmdSettings = mountCmdSettings[HM, GM] + var clientOptions clientOptions + (&clientOptions).BindFlags(flagSet) + *mo = append(*mo, func(ms *cmdSettings) error { + subset, err := clientOptions.make() + if err != nil { + return err + } + ms.clientSettings = subset + return nil + }) + var host HC = new(HT) + host.BindFlags(flagSet) + *mo = append(*mo, func(ms *cmdSettings) error { + marshaller, err := host.make() + if err != nil { + return err + } + ms.host = marshaller + return nil + }) + var guest GC = new(GT) + guest.BindFlags(flagSet) + *mo = append(*mo, func(ms *cmdSettings) error { + marshaller, err := guest.make() + if err != nil { + return err + } + ms.guest = marshaller + return nil + }) + const ( + prefix = "api-" + uidName = prefix + "uid" + uidUsage = "file owner's `uid`" + ) + flagSetFunc(flagSet, uidName, uidUsage, mo, + func(value p9.UID, settings *cmdSettings) error { + settings.apiOptions = append( + settings.apiOptions, + WithUID(value), + ) + return nil + }) + flagSet.Lookup(uidName). + DefValue = idString(apiUIDDefault) + const ( + gidName = prefix + "gid" + gidUsage = "file owner's `gid`" + ) + flagSetFunc(flagSet, gidName, gidUsage, mo, + func(value p9.GID, settings *cmdSettings) error { + settings.apiOptions = append( + settings.apiOptions, + WithGID(value), + ) + return nil + }) + flagSet.Lookup(gidName). + DefValue = idString(apiGIDDefault) + const ( + permissionsName = prefix + "permissions" + permissionsUsage = "`permissions` to use when creating service files" + ) + permissions := fs.FileMode(mountAPIPermissionsDefault &^ p9.FileModeMask) + flagSetFunc(flagSet, permissionsName, permissionsUsage, mo, + func(value string, settings *cmdSettings) error { + parsedPermissions, err := parsePOSIXPermissions(permissions, value) + if err != nil { + return err + } + permissions = parsedPermissions + // TODO: [2023.05.20] + // patch `.Permissions()` method in 9P library. + // For whatever reason the (unexported) + // const `p9.permissionsMask` is defined as `01777` + // but should be `0o7777` + permissions9 := modeFromFS(permissions) &^ p9.FileModeMask + settings.apiOptions = append( + settings.apiOptions, + WithPermissions(permissions9), + ) + return nil + }) + flagSet.Lookup(permissionsName). + DefValue = modeToSymbolicPermissions(permissions) +} + +func (mo mountCmdOptions[HT, GT, HM, GM, HC, GC]) make() (mountCmdSettings[HM, GM], error) { + return makeWithOptions(mo...) } +func (mp *mountCmdSettings[HM, GM]) marshalMountpoints(args ...string) ([][]byte, error) { + if len(args) == 0 { + args = []string{""} + } + data := make([][]byte, len(args)) + for i, arg := range args { + hostData, err := mp.host.marshal(arg) + if err != nil { + return nil, err + } + guestData, err := mp.guest.marshal(arg) + if err != nil { + return nil, err + } + datum, err := json.Marshal(struct { + Host json.RawMessage `json:"host,omitempty"` + Guest json.RawMessage `json:"guest,omitempty"` + }{ + Host: hostData, + Guest: guestData, + }) + if err != nil { + return nil, err + } + data[i] = datum + } + return data, nil +} + +// Mount constructs the command which requests +// the file system service to mount a system. func Mount() command.Command { const ( name = "mount" - synopsis = "Mount a file system." - usage = "Placeholder text." + synopsis = "Mount file systems." ) - return command.MakeCommand[*mountSettings](name, synopsis, usage, - subonlyExec[*mountSettings](), - command.WithSubcommands(makeMountSubcommands()...), + if subcommands := makeMountSubcommands(); len(subcommands) != 0 { + return command.SubcommandGroup(name, synopsis, makeMountSubcommands()) + } + const usage = "No mount host APIs were built into this executable." + return command.MakeNiladicCommand( + name, synopsis, usage, + func(ctx context.Context) error { + return command.UsageError{ + Err: generic.ConstError("no host systems"), + } + }, ) } -func mountFuse() command.Command { - const usage = "Placeholder text." +func makeMountSubcommands() []command.Command { + hosts := makeHostCommands() + sortCommands(hosts) + return hosts +} + +func makeMountSubcommand(host filesystem.Host, guestCommands []command.Command) command.Command { var ( - formalName = filesystem.Fuse.String() - cmdName = strings.ToLower(formalName) - synopsis = fmt.Sprintf("Mount a file system via the %s API.", formalName) + formalName = string(host) + commandName = strings.ToLower(formalName) + synopsis = fmt.Sprintf("Mount a file system via the %s API.", formalName) ) - return command.MakeCommand[*mountFuseSettings](cmdName, synopsis, usage, - subonlyExec[*mountFuseSettings](), - command.WithSubcommands(makeMountFuseSubcommands()...), + if len(guestCommands) > 0 { + return command.SubcommandGroup(commandName, synopsis, guestCommands) + } + const usage = "No mount guest APIs were built into this executable." + return command.MakeNiladicCommand( + commandName, synopsis, usage, + func(ctx context.Context) error { + return command.UsageError{ + Err: generic.ConstError("no guest systems"), + } + }, ) } -func makeMountSubcommands() []command.Command { +func makeHostCommands() []command.Command { + type makeCommand func() command.Command var ( - hostAPIs = []filesystem.API{ - filesystem.Fuse, - // TODO: ... + commandMakers = []makeCommand{ + makeFUSECommand, } - subcommands = make([]command.Command, len(hostAPIs)) + commands = make([]command.Command, 0, len(commandMakers)) ) - for i, hostAPI := range hostAPIs { - switch hostAPI { - case filesystem.Fuse: - subcommands[i] = mountFuse() - default: - panic("unexpected API ID for host file system interface") + for _, makeCommand := range commandMakers { + // Commands can be nil if system + // is disabled by build constraints. + if command := makeCommand(); command != nil { + commands = append(commands, command) } } - return subcommands + return commands } -func makeMountFuseSubcommands() []command.Command { - const usage = "Placeholder text." - var ( - formalName = filesystem.Fuse.String() - targetAPIs = []filesystem.ID{ - filesystem.IPFS, - filesystem.IPFSPins, - filesystem.IPNS, - filesystem.IPFSKeys, - // TODO: ... - } - subcommands = make([]command.Command, len(targetAPIs)) +func makeGuestCommands[ + HT any, + HM marshaller, + HC mountCmdHost[HT, HM], +](host filesystem.Host, +) []command.Command { + guests := makeIPFSCommands[HC, HM](host) + sortCommands(guests) + return guests +} + +func makeMountCommand[ + HC mountCmdHost[HT, HM], + HM marshaller, + GT any, + GM marshaller, + GC mountCmdGuest[GT, GM], + HT any, +](host filesystem.Host, guest filesystem.ID, +) command.Command { + type ( + MO = mountCmdOption[HT, GT, HM, GM, HC, GC] + MOS = mountCmdOptions[HT, GT, HM, GM, HC, GC] ) - for i, fsid := range targetAPIs { - var ( - fsName = fsid.String() - subcmdName = strings.ToLower(fsName) - synopsis = fmt.Sprintf("Mount %s via the %s API.", fsName, formalName) + var ( + hostFormalName = string(host) + guestFormalName = string(guest) + cmdName = strings.ToLower(guestFormalName) + synopsis = fmt.Sprintf( + "Mount %s via the %s API.", + guestFormalName, hostFormalName, ) - switch fsid { - case filesystem.IPFS, filesystem.IPFSPins, - filesystem.IPNS, filesystem.IPFSKeys: - subcommands[i] = command.MakeCommand[*mountIPFSSettings](subcmdName, synopsis, usage, - makeFuseIPFSExec(filesystem.Fuse, fsid), - ) - default: - panic("unexpected API ID for host file system interface") - } - } - return subcommands + usage = header(synopsis) + "\n\n" + + underline(hostFormalName) + "\n" + + HC(nil).usage(guest) + "\n\n" + + underline(guestFormalName) + "\n" + + GC(nil).usage(host) + ) + return command.MakeVariadicCommand[MOS](cmdName, synopsis, usage, + func(ctx context.Context, arguments []string, options ...MO) error { + settings, err := MOS(options).make() + if err != nil { + return err + } + data, err := settings.marshalMountpoints(arguments...) + if err != nil { + return err + } + const autoLaunchDaemon = true + client, err := settings.getClient(autoLaunchDaemon) + if err != nil { + return err + } + apiOptions := settings.apiOptions + if err := client.Mount(host, guest, data, apiOptions...); err != nil { + return errors.Join(err, client.Close()) + } + if err := client.Close(); err != nil { + return err + } + return ctx.Err() + }) } -func makeFuseIPFSExec(host filesystem.API, fsid filesystem.ID) func(context.Context, *mountIPFSSettings, ...string) error { - return func(ctx context.Context, set *mountIPFSSettings, args ...string) error { - return ipfsExecute(ctx, host, fsid, set, args...) +func (c *Client) Mount(host filesystem.Host, fsid filesystem.ID, data [][]byte, options ...MountOption) error { + set := mountSettings{ + permissions: mountAPIPermissionsDefault, + uid: apiUIDDefault, + gid: apiGIDDefault, } -} - -func ipfsExecute(ctx context.Context, host filesystem.API, fsid filesystem.ID, - set *mountIPFSSettings, args ...string, -) error { - // FIXME: [command] Doesn't the command library check for this already? - // We're seeing connections to the client when passed no args. - // ^ could also be our subcommand generator in this pkg. - if len(args) == 0 { - return command.ErrUsage + if err := generic.ApplyOptions(&set, options...); err != nil { + return err + } + mounts, err := (*p9.Client)(c).Attach(mountsFileName) + if err != nil { + return err } var ( - err error - serviceMaddr = set.serviceMaddr - ipfsMaddr = set.ipfsAPI - - client *daemon.Client - clientOpts []daemon.ClientOption - - // TODO: quick hack; do better - defaultServiceMaddr bool - // + hostName = string(host) + fsName = string(fsid) + wnames = []string{hostName, fsName} + permissions = set.permissions + uid = set.uid + gid = set.gid ) - if lazy, ok := serviceMaddr.(lazyFlag[multiaddr.Multiaddr]); ok { - if serviceMaddr, err = lazy.get(); err != nil { - return err - } - defaultServiceMaddr = true + guests, err := p9fs.MkdirAll(mounts, wnames, permissions, uid, gid) + if err != nil { + err = receiveError(mounts, err) + return errors.Join(err, mounts.Close()) } - if lazy, ok := ipfsMaddr.(lazyFlag[multiaddr.Multiaddr]); ok { - if ipfsMaddr, err = lazy.get(); err != nil { - return fmt.Errorf("could not retrieve IPFS node maddr, provide with -ipfs flag: %w", err) - } + const ( + mountIDLength = 9 + base58Alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + ) + idGen, err := nanoid.CustomASCII(base58Alphabet, mountIDLength) + if err != nil { + return errors.Join(err, mounts.Close(), guests.Close()) } - if set.verbose { - // TODO: less fancy prefix and/or out+prefix from CLI flags - clientLog := log.New(os.Stdout, "⬇️ client - ", log.Lshortfile) - clientOpts = append(clientOpts, daemon.WithLogger(clientLog)) + var ( + errs []error + filePermissions = permissions ^ (p9fs.ExecuteOther | p9fs.ExecuteGroup | p9fs.ExecuteUser) + ) + for _, data := range data { + name := fmt.Sprintf("%s.json", idGen()) + if err := newMountFile(guests, filePermissions, uid, gid, + name, data); err != nil { + errs = append(errs, err) + } } - if defaultServiceMaddr { - client, err = daemon.ConnectOrLaunchLocal(clientOpts...) - } else { - client, err = daemon.Connect(serviceMaddr, clientOpts...) + if errs != nil { + err = errors.Join(errs...) } + err = errors.Join(err, guests.Close()) if err != nil { - return err + err = receiveError(mounts, err) } + return errors.Join(err, mounts.Close()) +} - mountOpts := []daemon.MountOption{ - daemon.WithIPFS(ipfsMaddr), - } - if err := client.Mount(host, fsid, args, mountOpts...); err != nil { +func newMountFile(idRoot p9.File, + permissions p9.FileMode, uid p9.UID, gid p9.GID, + name string, data []byte, +) error { + _, idClone, err := idRoot.Walk(nil) + if err != nil { return err } - if err := client.Close(); err != nil { - return err + targetFile, _, _, err := idClone.Create(name, p9.WriteOnly, permissions, uid, gid) + if err != nil { + return errors.Join(err, idClone.Close()) } - - return ctx.Err() + // NOTE: targetFile and idClone are now aliased + // (same fid because of `Create`). + if _, err := targetFile.WriteAt(data, 0); err != nil { + return errors.Join(err, targetFile.Close()) + } + return targetFile.Close() } diff --git a/internal/commands/mountpoint.go b/internal/commands/mountpoint.go new file mode 100644 index 00000000..ba3ec88a --- /dev/null +++ b/internal/commands/mountpoint.go @@ -0,0 +1,257 @@ +package commands + +import ( + "errors" + "fmt" + "io" + "io/fs" + "strings" + + "github.com/djdv/go-filesystem-utils/internal/filesystem" + p9fs "github.com/djdv/go-filesystem-utils/internal/filesystem/9p" + "github.com/djdv/go-filesystem-utils/internal/generic" + "github.com/djdv/p9/p9" +) + +type ( + mountPointHost[T any] interface { + *T + p9fs.Mounter + p9fs.HostIdentifier + } + mountPointGuest[T any] interface { + *T + p9fs.SystemMaker + p9fs.GuestIdentifier + } + mountPoint[ + HT, GT any, + HC mountPointHost[HT], + GC mountPointGuest[GT], + ] struct { + Host HT `json:"host"` + Guest GT `json:"guest"` + } + mountPointHosts map[filesystem.Host]p9fs.MakeGuestFunc + mountPointGuests map[filesystem.ID]p9fs.MakeMountPointFunc +) + +func newMounter(parent p9.File, path ninePath, + uid p9.UID, gid p9.GID, permissions p9.FileMode, +) (mountSubsystem, error) { + const autoUnlink = true + _, mountFS, err := p9fs.NewMounter( + newMakeHostFunc(path, autoUnlink), + p9fs.WithParent[p9fs.MounterOption](parent, mountsFileName), + p9fs.WithPath[p9fs.MounterOption](path), + p9fs.WithUID[p9fs.MounterOption](uid), + p9fs.WithGID[p9fs.MounterOption](gid), + p9fs.WithPermissions[p9fs.MounterOption](permissions), + p9fs.UnlinkEmptyChildren[p9fs.MounterOption](autoUnlink), + p9fs.WithoutRename[p9fs.MounterOption](true), + ) + if err != nil { + return mountSubsystem{}, err + } + return mountSubsystem{ + name: mountsFileName, + MountFile: mountFS, + }, nil +} + +func newMakeHostFunc(path ninePath, autoUnlink bool) p9fs.MakeHostFunc { + hosts := makeMountPointHosts(path, autoUnlink) + return func(parent p9.File, host filesystem.Host, mode p9.FileMode, uid p9.UID, gid p9.GID) (p9.QID, p9.File, error) { + permissions, err := mountsDirCreatePreamble(mode) + if err != nil { + return p9.QID{}, nil, err + } + makeGuestFn, ok := hosts[host] + if !ok { + err := fmt.Errorf(`unexpected host "%v"`, host) + return p9.QID{}, nil, err + } + return p9fs.NewHostFile( + makeGuestFn, + p9fs.WithParent[p9fs.HosterOption](parent, string(host)), + p9fs.WithPath[p9fs.HosterOption](path), + p9fs.WithUID[p9fs.HosterOption](uid), + p9fs.WithGID[p9fs.HosterOption](gid), + p9fs.WithPermissions[p9fs.HosterOption](permissions), + p9fs.UnlinkEmptyChildren[p9fs.HosterOption](autoUnlink), + p9fs.UnlinkWhenEmpty[p9fs.HosterOption](autoUnlink), + p9fs.WithoutRename[p9fs.HosterOption](true), + ) + } +} + +func makeMountPointHosts(path ninePath, autoUnlink bool) mountPointHosts { + type makeHostsFunc func(ninePath, bool) (filesystem.Host, p9fs.MakeGuestFunc) + var ( + hostMakers = []makeHostsFunc{ + makeFUSEHost, + } + hosts = make(mountPointHosts, len(hostMakers)) + ) + for _, hostMaker := range hostMakers { + host, guestMaker := hostMaker(path, autoUnlink) + if guestMaker == nil { + continue // System (likely) disabled by build constraints. + } + // No clobbering, accidental or otherwise. + if _, exists := hosts[host]; exists { + err := fmt.Errorf( + "%s file constructor already registered", + host, + ) + panic(err) + } + hosts[host] = guestMaker + } + return hosts +} + +func newMakeGuestFunc(guests mountPointGuests, path ninePath, autoUnlink bool) p9fs.MakeGuestFunc { + return func(parent p9.File, guest filesystem.ID, mode p9.FileMode, uid p9.UID, gid p9.GID) (p9.QID, p9.File, error) { + permissions, err := mountsDirCreatePreamble(mode) + if err != nil { + return p9.QID{}, nil, err + } + makeMountPointFn, ok := guests[guest] + if !ok { + err := fmt.Errorf(`unexpected guest "%v"`, guest) + return p9.QID{}, nil, err + } + return p9fs.NewGuestFile( + makeMountPointFn, + p9fs.UnlinkEmptyChildren[p9fs.GuestOption](autoUnlink), + p9fs.UnlinkWhenEmpty[p9fs.GuestOption](autoUnlink), + p9fs.WithParent[p9fs.GuestOption](parent, string(guest)), + p9fs.WithPath[p9fs.GuestOption](path), + p9fs.WithUID[p9fs.GuestOption](uid), + p9fs.WithGID[p9fs.GuestOption](gid), + p9fs.WithPermissions[p9fs.GuestOption](permissions), + ) + } +} + +func makeMountPointGuests[ + T any, + HC mountPointHost[T], +](path ninePath, +) mountPointGuests { + guests := make(mountPointGuests) + makeIPFSGuests[HC](guests, path) + return guests +} + +func mountsDirCreatePreamble(mode p9.FileMode) (p9.FileMode, error) { + if !mode.IsDir() { + return 0, generic.ConstError("expected to be called from mkdir") + } + return mode.Permissions(), nil +} + +func newMountPointFunc[ + HC mountPointHost[HT], + GT any, + GC mountPointGuest[GT], + HT any, +](path ninePath, +) p9fs.MakeMountPointFunc { + return func(parent p9.File, name string, mode p9.FileMode, uid p9.UID, gid p9.GID) (p9.QID, p9.File, error) { + permissions, err := mountsFileCreatePreamble(mode) + if err != nil { + return p9.QID{}, nil, err + } + return p9fs.NewMountPoint[*mountPoint[HT, GT, HC, GC]]( + p9fs.WithParent[p9fs.MountPointOption](parent, name), + p9fs.WithPath[p9fs.MountPointOption](path), + p9fs.WithUID[p9fs.MountPointOption](uid), + p9fs.WithGID[p9fs.MountPointOption](gid), + p9fs.WithPermissions[p9fs.MountPointOption](permissions), + ) + } +} + +func mountsFileCreatePreamble(mode p9.FileMode) (p9.FileMode, error) { + if !mode.IsRegular() { + return 0, generic.ConstError("expected to be called from mknod") + } + return mode.Permissions(), nil +} + +func (mp *mountPoint[HT, GT, HC, GC]) ParseField(key, value string) error { + const ( + hostPrefix = "host." + guestPrefix = "guest." + ) + var ( + prefix string + parser p9fs.FieldParser + ) + const ( + // TODO: [Go 1.21] use [errors.ErrUnsupported]. + unsupported = generic.ConstError("unsupported operation") + unsupportedFmt = "%w: %T does not implement field parser" + ) + switch { + case strings.HasPrefix(key, hostPrefix): + prefix = hostPrefix + var ok bool + if parser, ok = any(&mp.Host).(p9fs.FieldParser); !ok { + return fmt.Errorf( + unsupportedFmt, + unsupported, &mp.Host, + ) + } + case strings.HasPrefix(key, guestPrefix): + prefix = guestPrefix + var ok bool + if parser, ok = any(&mp.Guest).(p9fs.FieldParser); !ok { + return fmt.Errorf( + unsupportedFmt, + unsupported, &mp.Guest, + ) + } + default: + const wildcard = "*" + return p9fs.FieldError{ + Key: key, + Tried: []string{hostPrefix + wildcard, guestPrefix + wildcard}, + } + } + var ( + baseKey = key[len(prefix):] + err = parser.ParseField(baseKey, value) + ) + if err == nil { + return nil + } + var fErr p9fs.FieldError + if !errors.As(err, &fErr) { + return err + } + tried := fErr.Tried + for i, e := range fErr.Tried { + tried[i] = prefix + e + } + fErr.Tried = tried + return fErr +} + +func (mp *mountPoint[HT, GT, HC, GC]) MakeFS() (fs.FS, error) { + return GC(&mp.Guest).MakeFS() +} + +func (mp *mountPoint[HT, GT, HC, GC]) Mount(fsys fs.FS) (io.Closer, error) { + return HC(&mp.Host).Mount(fsys) +} + +func (mp *mountPoint[HT, GT, HC, GC]) HostID() filesystem.Host { + return HC(&mp.Host).HostID() +} + +func (mp *mountPoint[HT, GT, HC, GC]) GuestID() filesystem.ID { + return GC(&mp.Guest).GuestID() +} diff --git a/internal/commands/mountpoint_fuse.go b/internal/commands/mountpoint_fuse.go new file mode 100644 index 00000000..c7c2714c --- /dev/null +++ b/internal/commands/mountpoint_fuse.go @@ -0,0 +1,214 @@ +//go:build !nofuse + +package commands + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/djdv/go-filesystem-utils/internal/command" + "github.com/djdv/go-filesystem-utils/internal/filesystem" + p9fs "github.com/djdv/go-filesystem-utils/internal/filesystem/9p" + "github.com/djdv/go-filesystem-utils/internal/filesystem/cgofuse" + "github.com/djdv/go-filesystem-utils/internal/generic" +) + +type ( + fuseSettings cgofuse.Host + fuseOption func(*fuseSettings) error + fuseOptions []fuseOption + fuseID uint32 +) + +const ( + fuseFlagPrefix = "fuse-" + fuseRawOptionsName = fuseFlagPrefix + "options" +) + +func makeFUSECommand() command.Command { + return makeMountSubcommand( + cgofuse.HostID, + makeGuestCommands[fuseOptions, fuseSettings](cgofuse.HostID), + ) +} + +func makeFUSEHost(path ninePath, autoUnlink bool) (filesystem.Host, p9fs.MakeGuestFunc) { + guests := makeMountPointGuests[cgofuse.Host](path) + return cgofuse.HostID, newMakeGuestFunc(guests, path, autoUnlink) +} + +func unmarshalFUSE() (filesystem.Host, decodeFunc) { + return cgofuse.HostID, func(b []byte) (string, error) { + var host cgofuse.Host + err := json.Unmarshal(b, &host) + return host.Point, err + } +} + +func (*fuseOptions) usage(guest filesystem.ID) string { + var ( + execName = filepath.Base(os.Args[0]) + commandName = strings.TrimSuffix( + execName, + filepath.Ext(execName), + ) + guestName = strings.ToLower(string(guest)) + hostName = strings.ToLower(string(cgofuse.HostID)) + exampleCommand = fmt.Sprintf( + "E.g. `%s mount %s %s %s`", + commandName, hostName, guestName, fuseExampleArgs, + ) + ) + return "Utilizes the `cgofuse` library" + + " to interface with the host system's FUSE API.\n\n" + + "Flags that are common across FUSE implementations" + + " are provided by this command,\nbut implementation" + + " specific flags may be passed directly to the FUSE" + + " library\nvia the `-" + fuseRawOptionsName + "` flag" + + " if required.\n\n" + + fuseHelpText + + "\n" + exampleCommand +} + +func (fo *fuseOptions) BindFlags(flagSet *flag.FlagSet) { + const ( + prefix = fuseFlagPrefix + optionsName = fuseRawOptionsName + optionsUsage = "raw options passed directly to mount" + + "\nmust be specified once per `FUSE flag`" + + "\n(E.g. `-" + optionsName + + ` "-o uid=0,gid=0" -` + + optionsName + " \"--VolumePrefix=somePrefix\"`)" + passthroughErr = "cannot combine" + + "-" + optionsName + "with built-in flags" + ) + var ( + passthroughFlags []string + explicitFlags []string + ) + flagSetFunc(flagSet, optionsName, optionsUsage, fo, + func(value string, settings *fuseSettings) error { + if len(explicitFlags) != 0 { + return fmt.Errorf("%s: %s", + passthroughErr, + strings.Join(explicitFlags, ","), + ) + } + passthroughFlags = append(passthroughFlags, value) + settings.Options = append(settings.Options, value) + return nil + }) + const ( + uidKind = "uid" + uidName = prefix + uidKind + gidKind = "gid" + gidName = prefix + gidKind + explicitErr = "cannot combine built-in flags" + + "with -" + optionsName + ) + var ( + uidUsage, uidDefaultText = fuseIDFlagText(uidKind) + gidUsage, gidDefaultText = fuseIDFlagText(gidKind) + combinedCheck = func() error { + if len(passthroughFlags) != 0 { + return fmt.Errorf("%s: %s", + explicitErr, + strings.Join(passthroughFlags, ","), + ) + } + return nil + } + ) + flagSetFunc(flagSet, uidName, uidUsage, fo, + func(value fuseID, settings *fuseSettings) error { + if err := combinedCheck(); err != nil { + return err + } + explicitFlags = append(explicitFlags, uidName) + settings.UID = uint32(value) + return nil + }) + flagSet.Lookup(uidName). + DefValue = uidDefaultText + flagSetFunc(flagSet, gidName, gidUsage, fo, + func(value fuseID, settings *fuseSettings) error { + if err := combinedCheck(); err != nil { + return err + } + explicitFlags = append(explicitFlags, gidName) + settings.GID = uint32(value) + return nil + }) + flagSet.Lookup(gidName). + DefValue = gidDefaultText + const ( + logName = prefix + "log" + logUsage = "sets a log `prefix` and enables logging in FUSE operations" + ) + flagSetFunc(flagSet, logName, logUsage, fo, + func(value string, settings *fuseSettings) error { + if value == "" { + return fmt.Errorf(`"%s" flag had empty value`, logName) + } + settings.LogPrefix = value + return nil + }) + const ( + readdirName = prefix + "readdir-plus" + readdirUsage = "informs the host that the hosted file system has the readdir-plus capability" + ) + flagSetFunc(flagSet, readdirName, readdirUsage, fo, + func(value bool, settings *fuseSettings) error { + settings.ReaddirPlus = value + return nil + }) + + flagSet.Lookup(readdirName). + DefValue = strconv.FormatBool(readdirPlusCapible) + const ( + caseName = prefix + "case-insensitive" + caseUsage = "informs the host that the hosted file system is case insensitive" + ) + flagSetFunc(flagSet, caseName, caseUsage, fo, + func(value bool, settings *fuseSettings) error { + settings.CaseInsensitive = value + return nil + }) + const ( + deleteName = prefix + "delete-access" + deleteUsage = "informs the host that the hosted file system implements \"Access\" which understands the \"DELETE_OK\" flag" + ) + flagSetFunc(flagSet, deleteName, deleteUsage, fo, + func(value bool, settings *fuseSettings) error { + settings.DeleteAccess = value + return nil + }) +} + +func (fo fuseOptions) make() (fuseSettings, error) { + settings := fuseSettings{ + UID: fuseUIDDefault, + GID: fuseGIDDefault, + ReaddirPlus: readdirPlusCapible, + } + return settings, generic.ApplyOptions(&settings, fo...) +} + +func (set fuseSettings) marshal(arg string) ([]byte, error) { + if arg == "" && + set.Options == nil { + err := command.UsageError{ + Err: generic.ConstError( + "expected mount point", + ), + } + return nil, err + } + set.Point = arg + return json.Marshal(set) +} diff --git a/internal/commands/mountpoint_fuse_other.go b/internal/commands/mountpoint_fuse_other.go new file mode 100644 index 00000000..3205cd9f --- /dev/null +++ b/internal/commands/mountpoint_fuse_other.go @@ -0,0 +1,26 @@ +//go:build !nofuse && !windows + +package commands + +import ( + "fmt" + "strings" +) + +const ( + fuseHelpText = "Valid mount points may be:\n" + + "- directory paths that refer to an existing directory (`/mnt/mountpoint`)\n" + fuseExampleArgs = `/mnt/ipfs` + fuseUIDDefault = 0 + fuseGIDDefault = 0 + readdirPlusCapible = false +) + +func fuseIDFlagText(kind string) (usageText, defaultText string) { + usageText = fmt.Sprintf( + "`%s` passed to FUSE"+ + kind, + strings.ToUpper(kind), + ) + return +} diff --git a/internal/commands/mountpoint_fuse_windows.go b/internal/commands/mountpoint_fuse_windows.go new file mode 100644 index 00000000..362b539c --- /dev/null +++ b/internal/commands/mountpoint_fuse_windows.go @@ -0,0 +1,28 @@ +package commands + +import ( + "fmt" + "strings" +) + +const ( + fuseHelpText = "Valid mount points may be:\n" + + "- drive letters (`X:`)\n" + + "- directory paths that do not refer to an existing file/directory (`X:\\mountpoint`)\n" + + "- UNC locations (`\\\\Server\\Share`)\n" + fuseExampleArgs = `M: C:\mountpoint \\localhost\mountpoint` + fuseUIDDefault = ^uint32(0) + fuseGIDDefault = ^uint32(0) + readdirPlusCapible = true +) + +func fuseIDFlagText(kind string) (usageText, defaultText string) { + usageText = fmt.Sprintf( + "`%s` passed to WinFSP"+ + "\n(use WinFSP's `fsptool id` to obtain SID<->%s mappings)", + kind, + strings.ToUpper(kind), + ) + defaultText = "caller of `mount`'s SID" + return +} diff --git a/internal/commands/mountpoint_ipfs.go b/internal/commands/mountpoint_ipfs.go new file mode 100644 index 00000000..9561207a --- /dev/null +++ b/internal/commands/mountpoint_ipfs.go @@ -0,0 +1,327 @@ +//go:build !noipfs + +package commands + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/djdv/go-filesystem-utils/internal/command" + "github.com/djdv/go-filesystem-utils/internal/filesystem" + "github.com/djdv/go-filesystem-utils/internal/filesystem/ipfs" + "github.com/djdv/go-filesystem-utils/internal/generic" + giconfig "github.com/ipfs/kubo/config" + "github.com/multiformats/go-multiaddr" +) + +type ( + ipfsSettings ipfs.IPFSGuest + ipfsOption func(*ipfsSettings) error + ipfsOptions []ipfsOption + pinFSSettings ipfs.PinFSGuest + pinFSOption func(*pinFSSettings) error + pinFSOptions []pinFSOption + ipnsSettings ipfs.IPNSGuest + ipnsOption func(*ipnsSettings) error + ipnsOptions []ipnsOption + keyFSSettings ipfs.KeyFSGuest + keyFSOption func(*keyFSSettings) error + keyFSOptions []keyFSOption +) + +const ( + ipfsAPIFileName = "api" + ipfsConfigEnv = giconfig.EnvDir + ipfsConfigDefaultDir = giconfig.DefaultPathRoot + pinfsExpiryDefault = 30 * time.Second + ipnsExpiryDefault = 1 * time.Minute +) + +func makeIPFSCommands[ + HC mountCmdHost[HT, HM], + HM marshaller, + HT any, +](host filesystem.Host, +) []command.Command { + return []command.Command{ + makeMountCommand[HC, HM, ipfsOptions, ipfsSettings](host, ipfs.IPFSID), + makeMountCommand[HC, HM, pinFSOptions, pinFSSettings](host, ipfs.PinFSID), + makeMountCommand[HC, HM, ipnsOptions, ipnsSettings](host, ipfs.IPNSID), + makeMountCommand[HC, HM, keyFSOptions, keyFSSettings](host, ipfs.KeyFSID), + } +} + +func makeIPFSGuests[ + HC mountPointHost[T], + T any, +](guests mountPointGuests, path ninePath, +) { + guests[ipfs.IPFSID] = newMountPointFunc[HC, ipfs.IPFSGuest](path) + guests[ipfs.IPNSID] = newMountPointFunc[HC, ipfs.IPNSGuest](path) + guests[ipfs.KeyFSID] = newMountPointFunc[HC, ipfs.KeyFSGuest](path) + guests[ipfs.PinFSID] = newMountPointFunc[HC, ipfs.PinFSGuest](path) +} + +func guestOverlayText(overlay, overlaid filesystem.ID) string { + return string(overlay) + " is an " + string(overlaid) + " overlay" +} + +func prefixIDFlag(system filesystem.ID) string { + return strings.ToLower(string(system)) + "-" +} + +func (*ipfsOptions) usage(filesystem.Host) string { + return string(ipfs.IPFSID) + " provides an empty root directory." + + "\nChild paths are forwarded to the IPFS API." +} + +func (io *ipfsOptions) BindFlags(flagSet *flag.FlagSet) { + io.bindFlagsVarient(ipfs.IPFSID, flagSet) +} + +func (io *ipfsOptions) bindFlagsVarient(system filesystem.ID, flagSet *flag.FlagSet) { + var ( + flagPrefix = prefixIDFlag(system) + apiUsage = string(system) + " API node `maddr`" + apiName = flagPrefix + ipfsAPIFileName + ) + flagSetFunc(flagSet, apiName, apiUsage, io, + func(value multiaddr.Multiaddr, settings *ipfsSettings) error { + settings.APIMaddr = value + return nil + }) + flagSet.Lookup(apiName). + DefValue = fmt.Sprintf( + "parses: %s, %s", + filepath.Join("$"+ipfsConfigEnv, ipfsAPIFileName), + filepath.Join(ipfsConfigDefaultDir, ipfsAPIFileName), + ) + const timeoutUsage = "timeout `duration` to use when communicating" + + " with the API" + + "\nif <= 0, operations will remain pending" + + " until the operation completes, or the file or system is closed" + timeoutName := flagPrefix + "timeout" + flagSetFunc(flagSet, timeoutName, timeoutUsage, io, + func(value time.Duration, settings *ipfsSettings) error { + settings.APITimeout = value + return nil + }) + nodeCacheName := flagPrefix + "node-cache" + const ( + defaultCacheCount = 64 + nodeCacheUsage = "number of nodes to keep in the cache" + + "\nnegative values disable node caching" + ) + flagSetFunc(flagSet, nodeCacheName, nodeCacheUsage, io, + func(value int, settings *ipfsSettings) error { + settings.NodeCacheCount = value + return nil + }) + dirCacheName := flagPrefix + "directory-cache" + const dirCacheUsage = "number of directory entry lists to keep in the cache" + + "\nnegative values disable directory caching" + flagSetFunc(flagSet, dirCacheName, dirCacheUsage, io, + func(value int, settings *ipfsSettings) error { + settings.DirectoryCacheCount = value + return nil + }) +} + +func (io ipfsOptions) make() (ipfsSettings, error) { + settings, err := makeWithOptions(io...) + if err != nil { + return ipfsSettings{}, err + } + if settings.APIMaddr == nil { + maddrs, err := getIPFSAPI() + if err != nil { + return ipfsSettings{}, fmt.Errorf( + "could not get default value for API: %w", + err, + ) + } + settings.APIMaddr = maddrs[0] + } + return settings, nil +} + +func (set ipfsSettings) marshal(string) ([]byte, error) { + return json.Marshal(set) +} + +func (*pinFSOptions) usage(filesystem.Host) string { + return guestOverlayText(ipfs.PinFSID, ipfs.IPFSID) + + " which provides a root containing" + + "\nentries for each recursive pin from the IPFS node." + + "\nChild paths are forwarded to IPFS." +} + +func (po *pinFSOptions) BindFlags(flagSet *flag.FlagSet) { + var ipfsOptions ipfsOptions + (&ipfsOptions).bindFlagsVarient(ipfs.PinFSID, flagSet) + *po = append(*po, func(settings *pinFSSettings) error { + subset, err := ipfsOptions.make() + if err != nil { + return err + } + settings.IPFSGuest = ipfs.IPFSGuest(subset) + return nil + }) + const ( + expiryName = "pinfs-expiry" + expiryUsage = "`duration` pins are cached for" + + "\nnegative values retain cache forever, 0 disables cache" + ) + flagSetFunc(flagSet, expiryName, expiryUsage, po, + func(value time.Duration, settings *pinFSSettings) error { + settings.CacheExpiry = value + return nil + }) + flagSet.Lookup(expiryName). + DefValue = pinfsExpiryDefault.String() +} + +func (po pinFSOptions) make() (pinFSSettings, error) { + return makeWithOptions(po...) +} + +func (set pinFSSettings) marshal(string) ([]byte, error) { + return json.Marshal(set) +} + +func (*ipnsOptions) usage(filesystem.Host) string { + return guestOverlayText(ipfs.IPNSID, ipfs.IPFSID) + + " which provides an empty root." + + "\nThe first element in a child path is resolved via IPNS." + + "\nSubsequent paths are forwarded to IPFS (rooted under the resolved IPNS name)." +} + +func (no *ipnsOptions) BindFlags(flagSet *flag.FlagSet) { + no.bindFlagsVarient(ipfs.IPNSID, flagSet) +} + +func (no *ipnsOptions) bindFlagsVarient(system filesystem.ID, flagSet *flag.FlagSet) { + var ipfsOptions ipfsOptions + (&ipfsOptions).bindFlagsVarient(system, flagSet) + *no = append(*no, func(settings *ipnsSettings) error { + subset, err := ipfsOptions.make() + if err != nil { + return err + } + settings.IPFSGuest = ipfs.IPFSGuest(subset) + return nil + }) + var ( + flagPrefix = prefixIDFlag(system) + expiryName = flagPrefix + "expiry" + ) + const ( + expiryUsage = "`duration` of how long a node is considered" + + "valid within the cache" + + "\nafter this time, the node will be refreshed during" + + " its next operation" + ) + flagSetFunc(flagSet, expiryName, expiryUsage, no, + func(value time.Duration, settings *ipnsSettings) error { + settings.NodeExpiry = value + return nil + }) + flagSet.Lookup(expiryName). + DefValue = ipnsExpiryDefault.String() +} + +func (no ipnsOptions) make() (ipnsSettings, error) { + settings := ipnsSettings{ + NodeExpiry: ipnsExpiryDefault, + } + return settings, generic.ApplyOptions(&settings, no...) +} + +func (set ipnsSettings) marshal(string) ([]byte, error) { + return json.Marshal(set) +} + +func (*keyFSOptions) usage(filesystem.Host) string { + return guestOverlayText(ipfs.KeyFSID, ipfs.IPNSID) + + " which provides a root" + + "\ncontaining entries for each IPNS key from the IPFS node." + + "\nChild paths are forwarded to IPNS after being resolved." +} + +func (ko *keyFSOptions) BindFlags(flagSet *flag.FlagSet) { + var ipnsOptions ipnsOptions + (&ipnsOptions).bindFlagsVarient(ipfs.KeyFSID, flagSet) + *ko = append(*ko, func(settings *keyFSSettings) error { + subset, err := ipnsOptions.make() + if err != nil { + return err + } + settings.IPNSGuest = ipfs.IPNSGuest(subset) + return nil + }) +} + +func (ko keyFSOptions) make() (keyFSSettings, error) { + return makeWithOptions(ko...) +} + +func (set keyFSSettings) marshal(string) ([]byte, error) { + return json.Marshal(set) +} + +func getIPFSAPI() ([]multiaddr.Multiaddr, error) { + location, err := getIPFSAPIPath() + if err != nil { + return nil, err + } + if !apiFileExists(location) { + return nil, generic.ConstError("IPFS API file not found") + } + return parseIPFSAPI(location) +} + +func getIPFSAPIPath() (string, error) { + var target string + if ipfsPath, set := os.LookupEnv(ipfsConfigEnv); set { + target = filepath.Join(ipfsPath, ipfsAPIFileName) + } else { + target = filepath.Join(ipfsConfigDefaultDir, ipfsAPIFileName) + } + return expandHomeShorthand(target) +} + +func expandHomeShorthand(name string) (string, error) { + if !strings.HasPrefix(name, "~") { + return name, nil + } + homeName, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(homeName, name[1:]), nil +} + +func apiFileExists(name string) bool { + _, err := os.Stat(name) + return err == nil +} + +func parseIPFSAPI(name string) ([]multiaddr.Multiaddr, error) { + // NOTE: [upstream problem] + // If the config file has multiple API maddrs defined, + // only the first one will be contained in the API file. + maddrString, err := os.ReadFile(name) + if err != nil { + return nil, err + } + maddr, err := multiaddr.NewMultiaddr(string(maddrString)) + if err != nil { + return nil, err + } + return []multiaddr.Multiaddr{maddr}, nil +} diff --git a/internal/commands/mountpoint_nofuse.go b/internal/commands/mountpoint_nofuse.go new file mode 100644 index 00000000..d16c2951 --- /dev/null +++ b/internal/commands/mountpoint_nofuse.go @@ -0,0 +1,25 @@ +//go:build nofuse + +package commands + +import ( + "github.com/djdv/go-filesystem-utils/internal/command" + "github.com/djdv/go-filesystem-utils/internal/filesystem" + p9fs "github.com/djdv/go-filesystem-utils/internal/filesystem/9p" +) + +type fuseID uint32 + +const fuseHost = filesystem.Host("") + +func makeFUSECommand() command.Command { + return nil +} + +func makeFUSEHost(ninePath, bool) (filesystem.Host, p9fs.MakeGuestFunc) { + return fuseHost, nil +} + +func unmarshalFUSE() (filesystem.Host, decodeFunc) { + return fuseHost, nil +} diff --git a/internal/commands/mountpoint_noipfs.go b/internal/commands/mountpoint_noipfs.go new file mode 100644 index 00000000..58e7dd5d --- /dev/null +++ b/internal/commands/mountpoint_noipfs.go @@ -0,0 +1,23 @@ +//go:build noipfs + +package commands + +import ( + "github.com/djdv/go-filesystem-utils/internal/command" + "github.com/djdv/go-filesystem-utils/internal/filesystem" +) + +func makeIPFSCommands[ + HC mountCmdHost[HT, HM], + HM marshaller, + HT any, +](filesystem.Host, +) []command.Command { + return nil +} + +func makeIPFSGuests[ + HC mountPointHost[T], + T any, +](mountPointGuests, ninePath, +) { /* NOOP */ } diff --git a/internal/commands/permission_linux_test.go b/internal/commands/permission_linux_test.go new file mode 100644 index 00000000..7dbeeba2 --- /dev/null +++ b/internal/commands/permission_linux_test.go @@ -0,0 +1,208 @@ +//go:build linux + +package commands + +import ( + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "testing" +) + +func TestPermissionSymbolizer(t *testing.T) { + t.Parallel() + for _, test := range []struct { + string + fs.FileMode + }{ + { + FileMode: 0o777 | fs.ModeSetuid, + string: "u=rwxs,g=rwx,o=rwx", + }, + { + FileMode: 0o777 | fs.ModeSetgid, + string: "u=rwx,g=rwxs,o=rwx", + }, + { + FileMode: 0o777 | fs.ModeSticky, + string: "u=rwx,g=rwx,o=rwxt", + }, + { + FileMode: 0o777 | fs.ModeSetuid | fs.ModeSetgid | fs.ModeSticky, + string: "u=rwxs,g=rwxs,o=rwxt", + }, + { + FileMode: 0o751, + string: "u=rwx,g=rx,o=x", + }, + { + FileMode: 0o704, + string: "u=rwx,o=r", + }, + } { + var ( + mode = test.FileMode + got = modeToSymbolicPermissions(mode) + want = test.string + ) + if got != want { + t.Errorf("unexpected symbolic representation of \"%o\""+ + "\n\tgot: %s"+ + "\n\twant: %s", + mode, got, want, + ) + } + } +} + +func TestParsePOSIXPermissions(t *testing.T) { + t.Parallel() + t.Run("valid", parsePOSIXPermissionsValid) + t.Run("invalid", parsePOSIXPermissionsInvalid) +} + +func parsePOSIXPermissionsValid(t *testing.T) { + t.Parallel() + var ( + testDir = t.TempDir() + testFile, err = os.CreateTemp(testDir, "permission-test-file") + fileName = testFile.Name() + ) + if err != nil { + t.Fatal(err) + } + defer os.Remove(fileName) + fileStat, err := testFile.Stat() + if err != nil { + t.Fatal(err) + } + fileMode := fileStat.Mode() + + dirPath := filepath.Join(testDir, "permission-test-dir") + if err := os.Mkdir(dirPath, 0o751); err != nil { + t.Fatal(err) + } + defer os.Remove(dirPath) + dirStat, err := os.Stat(dirPath) + if err != nil { + t.Fatal(err) + } + dirMode := dirStat.Mode() + clausesList := []string{ + "0", + "644", + "755", + "777", + "01751", + "02751", + "04751", + "07777", + "=rwx", + "a+", + "a+=", + "++w", + "--w", + "a++w", + "a--w", + "go+-w", + "g=o-w", + "g-r+w", + "uo=g", + "u+rwx", + "u=rw,g=rx,o=", + "a-rwx,u=rw,g+x,o-rw", + "u+r,g+w,o+x", + "u-w,g-r,o-x", + "u=rwx,g=rx,o=r", + "u+rw,g=x,o-w", + "u-w,g=r,o+x", + "u=r,g+w,o-r", + "0754", + "o=u-g", + "=", + "=X", + "777", + "=X", + "a=", + "u=x", + "g=X", + "u=s", + "g=s", + "o=s", + "a=s", + "=", + "g=s", + "=xt", + } + const utilityName = `chmod` + compare := func(mode fs.FileMode, targetName, clauses string, cmdArgs []string) (fs.FileMode, error) { + stdio, err := exec.Command(utilityName, cmdArgs...).CombinedOutput() + if err != nil { + t.Fatalf("%v\n%s", err, stdio) + } + info, err := os.Stat(targetName) + if err != nil { + t.Fatal(err) + } + want := info.Mode() + got, err := parsePOSIXPermissions(mode, clauses) + if err != nil { + return want, fmt.Errorf("\"%s\": %v", clauses, err) + } + if got != want { + return want, fmt.Errorf("unexpected permissions for clause(s) \"%s\""+ + "\n\tinitial: %s"+ + "\n\tgot: %s"+ + "\n\twant: %s", + clauses, mode, got, want, + ) + } + return want, nil + } + for _, clauses := range clausesList { + // NOTE: "--" is only required for portability. + // Systems that parse arguments with something like `getopt` (e.g. GNU) + // require this, while most SYSV implementations (e.g. Illumos) do not. + cmdArgs := []string{"--", clauses, fileName} + wantFile, err := compare(fileMode, fileName, clauses, cmdArgs) + fileMode = wantFile + if err != nil { + t.Error(err) + } + cmdArgs[len(cmdArgs)-1] = dirPath + wantDir, err := compare(dirMode, dirPath, clauses, cmdArgs) + dirMode = wantDir + if err != nil { + t.Error(err) + } + } +} + +func parsePOSIXPermissionsInvalid(t *testing.T) { + t.Parallel() + clausesList := []string{ + "invalid", + "🔐", + "u=🔐", + "u+x, o+x", + "u+x,,o+x", + "u+abc", + "j=rwx", + "u?rwx", + "u=go", + "888", + "0888", + "123456", + } + for _, clauses := range clausesList { + got, err := parsePOSIXPermissions(0, clauses) + if err == nil { + t.Errorf( + "expected error for clause(s) \"%s\" but got mode: %s", + clauses, got, + ) + } + } +} diff --git a/internal/commands/proc.go b/internal/commands/proc.go new file mode 100644 index 00000000..22a43ab0 --- /dev/null +++ b/internal/commands/proc.go @@ -0,0 +1,235 @@ +package commands + +import ( + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "sync" + "time" + + "github.com/djdv/go-filesystem-utils/internal/generic" + "github.com/djdv/p9/p9" + "github.com/multiformats/go-multiaddr" +) + +type ( + cmdIO struct { + in io.WriteCloser + out io.ReadCloser + closeErr error + once sync.Once + } + cmdIPCSignal = byte +) + +const ( + EOT = 0x4 + ipcProcRelease cmdIPCSignal = EOT + // stdio Clients should signal this file + // immediately before closing, so the subprocess + // can be aware it is about to be decoupled. + ipcReleaseFileName = "release" +) + +func (cio *cmdIO) Read(p []byte) (n int, err error) { + return cio.out.Read(p) +} + +func (cio *cmdIO) Write(p []byte) (n int, err error) { + return cio.in.Write(p) +} + +func (cio *cmdIO) Close() (err error) { + cio.once.Do(func() { + var errs []error + for _, c := range []io.Closer{cio.in, cio.out} { + if cErr := c.Close(); cErr != nil { + errs = append(errs, cErr) + } + } + if errs != nil { + cio.closeErr = errors.Join(errs...) + } + }) + return cio.closeErr +} + +func spawnDaemonProc(exitInterval time.Duration) (*exec.Cmd, *cmdIO, io.ReadCloser, error) { + cmd, err := newDaemonCommand(exitInterval) + if err != nil { + return nil, nil, nil, err + } + cmd.SysProcAttr = emancipatedSubproc() + cmdIO, stderr, err := setupCmdIPC(cmd) + if err != nil { + return nil, nil, nil, err + } + if err := cmd.Start(); err != nil { + return nil, nil, nil, errors.Join(err, cmdIO.Close(), stderr.Close()) + } + return cmd, cmdIO, stderr, nil +} + +func newDaemonCommand(exitInterval time.Duration) (*exec.Cmd, error) { + self, err := os.Executable() + if err != nil { + return nil, err + } + const ( + mandatoryArgs = 1 + likelyArgs = 2 + ) + args := make([]string, mandatoryArgs, likelyArgs) + args[0] = daemonCommandName + if exitInterval > 0 { + args = append(args, + fmt.Sprintf( + "-%s=%s", + exitAfterFlagName, exitInterval, + ), + ) + } + return exec.Command(self, args...), nil +} + +func setupCmdIPC(cmd *exec.Cmd) (*cmdIO, io.ReadCloser, error) { + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, nil, err + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, nil, errors.Join(err, stdin.Close()) + } + stderr, err := cmd.StderrPipe() + if err != nil { + return nil, nil, errors.Join(err, stdin.Close(), stdout.Close()) + } + return &cmdIO{in: stdin, out: stdout}, stderr, nil +} + +func getListenersFromProc(ipc io.ReadWriteCloser, stderr io.ReadCloser, options ...p9.ClientOpt) ([]multiaddr.Multiaddr, error) { + var ( + stdErrs = watchStderr(stderr) + client, err = newClient(ipc, options...) + errs []error + ) + if err != nil { + errs = append(errs, fmt.Errorf( + "could not connect to daemon: %w", err, + )) + // An errant process should close stderr, + // but we won't trust it. + const exitGrace = 512 * time.Millisecond + select { + case err := <-stdErrs: + if err != nil { + errs = append(errs, err) + } + case <-time.After(exitGrace): // Rogue process. + } + return nil, errors.Join(errs...) + } + var ( + done = make(chan struct{}) + maddrs []multiaddr.Multiaddr + fetchListeners = func() { + defer close(done) + var err error + if maddrs, err = client.getListeners(); err != nil { + errs = append(errs, fmt.Errorf( + "subproccess protocol error: %w", err, + )) + } + } + accumulateErr = func(err error) { + if err != nil { + errs = append(errs, err) + } + } + ) + go fetchListeners() + select { + case <-done: + if errs != nil { + accumulateErr(client.Shutdown(patientShutdown)) + } else if len(maddrs) == 0 { + errs = append(errs, generic.ConstError( + "daemon didn't return any addresses", + )) + } + accumulateErr(client.ipcRelease()) + accumulateErr(stderr.Close()) + case err := <-stdErrs: + errs = append(errs, err, stderr.Close()) + } + accumulateErr(client.Close()) + if errs != nil { + return nil, errors.Join(errs...) + } + return maddrs, nil +} + +func watchStderr(stderr io.Reader) <-chan error { + errs := make(chan error, 1) + go func() { + defer close(errs) + stdErr, err := io.ReadAll(stderr) + if err != nil { + errs <- err + return + } + if len(stdErr) != 0 { + errs <- fmt.Errorf( + "subprocess stderr:"+ + "\n%s", + stdErr, + ) + } + }() + return errs +} + +func (c *Client) ipcRelease() error { + controlDir, err := (*p9.Client)(c).Attach(controlFileName) + if err != nil { + return err + } + _, releaseFile, err := controlDir.Walk([]string{ipcReleaseFileName}) + if err != nil { + err = receiveError(controlDir, err) + return errors.Join(err, controlDir.Close()) + } + if _, _, err := releaseFile.Open(p9.WriteOnly); err != nil { + err = receiveError(controlDir, err) + return errors.Join(err, releaseFile.Close(), controlDir.Close()) + } + data := []byte{ipcProcRelease} + if _, err := releaseFile.WriteAt(data, 0); err != nil { + err = receiveError(controlDir, err) + return errors.Join(err, releaseFile.Close(), controlDir.Close()) + } + return errors.Join(releaseFile.Close(), controlDir.Close()) +} + +func maybeKill(cmd *exec.Cmd) error { + proc := cmd.Process + if proc == nil { + return nil + } + if !procRunning(proc) { + return nil + } + if err := proc.Kill(); err != nil { + var ( + pid = proc.Pid + name = filepath.Base(cmd.Path) + ) + return fmt.Errorf("could not terminate subprocess (ID: %d; %s): %w", + pid, name, err) + } + return nil +} diff --git a/internal/commands/proc_unix.go b/internal/commands/proc_unix.go new file mode 100644 index 00000000..929757ae --- /dev/null +++ b/internal/commands/proc_unix.go @@ -0,0 +1,31 @@ +//go:build unix + +package commands + +import ( + "os" + "os/signal" + "syscall" +) + +func procRunning(proc *os.Process) bool { + // SUSv4;BSi7 - kill + // If sig is 0, error checking is performed but no signal is actually sent. + return proc.Signal(syscall.Signal(0)) != nil +} + +func childProcInit() { + // See: [os/signal] documentation. + // If our parent process doesn't follow + // protocol, writes to stdio will get us + // killed by the OS. Ignoring this signal + // will let use receive a less harmful + // `EPIPE` error value. + signal.Ignore(syscall.SIGPIPE) +} + +func emancipatedSubproc() *syscall.SysProcAttr { + return &syscall.SysProcAttr{ + Setpgid: true, + } +} diff --git a/internal/commands/proc_windows.go b/internal/commands/proc_windows.go new file mode 100644 index 00000000..2d6a156d --- /dev/null +++ b/internal/commands/proc_windows.go @@ -0,0 +1,39 @@ +package commands + +import ( + "os" + "syscall" + + "golang.org/x/sys/windows" +) + +func procRunning(proc *os.Process) bool { + // NOTE: proc already contains a handle, but it's unexported. + // We could ask the runtime to let us read it, but this is safer. + const ( + STILL_ACTIVE = windows.STATUS_PENDING + desiredAccess = windows.PROCESS_QUERY_LIMITED_INFORMATION + inheritHandle = false + ) + pid := uint32(proc.Pid) + handle, err := windows.OpenProcess(desiredAccess, inheritHandle, pid) + if err != nil { + return false + } + defer windows.CloseHandle(handle) + var exitcode uint32 + if windows.GetExitCodeProcess(handle, &exitcode) != nil { + return false + } + return windows.NTStatus(exitcode) == STILL_ACTIVE +} + +func childProcInit() { /* NOOP */ } + +func emancipatedSubproc() *syscall.SysProcAttr { + return &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: windows.CREATE_NEW_PROCESS_GROUP | + windows.DETACHED_PROCESS, + } +} diff --git a/internal/commands/shared.go b/internal/commands/shared.go new file mode 100644 index 00000000..33f2608f --- /dev/null +++ b/internal/commands/shared.go @@ -0,0 +1,51 @@ +package commands + +const ( + // serverRootName defines a name which servers and clients may use + // to refer to the service in namespace oriented APIs. + // E.g. a socket's parent directory. + serverRootName = "fs" + + // serverName defines a name which servers and clients may use + // to form or find connections to a named server instance. + // E.g. a socket of path `/$ServerRootName/$serverName`. + serverName = "server" + + // apiFlagPrefix should be prepended to all flag names + // that relate to the `fs` service itself. + apiFlagPrefix = "api-" + + // serverFlagName is used by server and client commands + // to specify the listening channel; + // typically a socket multiaddr. + serverFlagName = apiFlagPrefix + "server" + + // exitAfterFlagName is used by server and client commands + // to specify the idle check interval for the server. + // Client commands will relay this to server instances + // if they spawn one. Otherwise it is ignored (after parsing). + exitAfterFlagName = apiFlagPrefix + "exit-after" + + // For names; all commands that interact with + // that file's protocol should use appropriate + // constants to resolve the [p9.File] via `Walk`. + + // mountsFileName is the name used by servers + // to host a [p9fs.MountFile]. + mountsFileName = "mounts" + + // listenersFileName is the name used by servers + // to host a [p9fs.Listener]. + listenersFileName = "listeners" + + // controlFileName is the name used by servers + // to host 9P directory containing various server + // control files. + controlFileName = "control" + + // shutdownFileName is the name used by servers + // to host a 9P file used to request shutdown, + // by writing a [shutdownDisposition] (string or byte) + // value to the file. + shutdownFileName = "shutdown" +) diff --git a/internal/commands/shutdown.go b/internal/commands/shutdown.go index 02e24ef4..d557aa30 100644 --- a/internal/commands/shutdown.go +++ b/internal/commands/shutdown.go @@ -2,50 +2,180 @@ package commands import ( "context" - "log" - "os" + "errors" + "flag" + "fmt" + "strings" + "text/tabwriter" "github.com/djdv/go-filesystem-utils/internal/command" - "github.com/djdv/go-filesystem-utils/internal/daemon" - "github.com/multiformats/go-multiaddr" + "github.com/djdv/go-filesystem-utils/internal/generic" + "github.com/djdv/p9/p9" ) -type shutdownSettings struct{ clientSettings } +type ( + shutdownDisposition uint8 + shutdownSettings struct { + clientSettings + disposition shutdownDisposition + } + shutdownOption func(*shutdownSettings) error + shutdownOptions []shutdownOption +) +const ( + dontShutdown shutdownDisposition = iota + patientShutdown + shortShutdown + immediateShutdown + minimumShutdown = patientShutdown + maximumShutdown = immediateShutdown + dispositionDefault = patientShutdown +) + +func (level shutdownDisposition) String() string { + switch level { + case patientShutdown: + return "patient" + case shortShutdown: + return "short" + case immediateShutdown: + return "immediate" + default: + return fmt.Sprintf("invalid: %d", level) + } +} + +// Shutdown constructs the command which +// requests the file system service to stop. func Shutdown() command.Command { const ( name = "shutdown" - synopsis = "Stop the service." - usage = "Placeholder text." + synopsis = "Stop the system service." ) - return command.MakeCommand[*shutdownSettings](name, synopsis, usage, shutdownExecute) + usage := header("Shutdown") + + "\n\nRequest to stop the file system services." + return command.MakeVariadicCommand[shutdownOptions](name, synopsis, usage, shutdownExecute) } -func shutdownExecute(ctx context.Context, set *shutdownSettings, _ ...string) error { - var clientOpts []daemon.ClientOption - if set.verbose { - // TODO: less fancy prefix and/or out+prefix from CLI flags - clientLog := log.New(os.Stdout, "⬇️ client - ", log.Lshortfile) - clientOpts = append(clientOpts, daemon.WithLogger(clientLog)) - } +func (so *shutdownOptions) BindFlags(flagSet *flag.FlagSet) { + var clientOptions clientOptions + (&clientOptions).BindFlags(flagSet) + *so = append(*so, func(ss *shutdownSettings) error { + subset, err := clientOptions.make() + if err != nil { + return err + } + ss.clientSettings = subset + return nil + }) + const shutdownName = "level" + shutdownUsage := fmt.Sprintf( + "sets the `disposition` for shutdown"+ + "\none of:"+ + "\n%s", + shutdownLevelsTable(), + ) + flagSetFunc(flagSet, shutdownName, shutdownUsage, so, + func(value shutdownDisposition, settings *shutdownSettings) error { + settings.disposition = value + return nil + }) + flagSet.Lookup(shutdownName). + DefValue = dispositionDefault.String() +} - // TODO: signalctx + shutdown on cancel +func (so shutdownOptions) make() (shutdownSettings, error) { + settings := shutdownSettings{ + disposition: dispositionDefault, + } + return settings, generic.ApplyOptions(&settings, so...) +} - serviceMaddr := set.serviceMaddr - if lazy, ok := serviceMaddr.(lazyFlag[multiaddr.Multiaddr]); ok { - var err error - if serviceMaddr, err = lazy.get(); err != nil { - return err +func shutdownLevelsTable() string { + // [upstream] glamour prepends a newline to lists + // which can not be disabled. So we don't use them here. :^/ + const ( + minWidth = 0 + tabWidth = 0 + padding = 0 + padChar = ' ' + flags = 0 + ) + var ( + levelsBuffer strings.Builder + tabWriter = tabwriter.NewWriter( + &levelsBuffer, minWidth, tabWidth, padding, padChar, flags, + ) + ) + for _, pair := range []struct { + description string + level shutdownDisposition + }{ + { + level: patientShutdown, + description: "waits for connections to become idle before closing", + }, + { + level: shortShutdown, + description: "forcibly closes connections after a short delay", + }, + { + level: immediateShutdown, + description: "forcibly closes connections immediately", + }, + } { + if _, err := fmt.Fprintf( + tabWriter, + "`%s`\t - %s\n", + pair.level, pair.description, + ); err != nil { + panic(err) } } + if err := tabWriter.Flush(); err != nil { + panic(err) + } + return levelsBuffer.String() +} - client, err := daemon.Connect(serviceMaddr, clientOpts...) +func shutdownExecute(ctx context.Context, options ...shutdownOption) error { + settings, err := shutdownOptions(options).make() if err != nil { return err } - if err := client.Shutdown(serviceMaddr); err != nil { + const autoLaunchDaemon = false + client, err := settings.getClient(autoLaunchDaemon) + if err != nil { + return fmt.Errorf("could not get client (server already down?): %w", err) + } + if err := client.Shutdown(settings.disposition); err != nil { + return errors.Join(err, client.Close()) + } + if err := client.Close(); err != nil { return err } - return ctx.Err() } + +func (c *Client) Shutdown(level shutdownDisposition) error { + controlDir, err := (*p9.Client)(c).Attach(controlFileName) + if err != nil { + return err + } + _, shutdownFile, err := controlDir.Walk([]string{shutdownFileName}) + if err != nil { + err = receiveError(controlDir, err) + return errors.Join(err, controlDir.Close()) + } + if _, _, err := shutdownFile.Open(p9.WriteOnly); err != nil { + err = receiveError(controlDir, err) + return errors.Join(err, shutdownFile.Close(), controlDir.Close()) + } + data := []byte{byte(level)} + if _, err := shutdownFile.WriteAt(data, 0); err != nil { + err = receiveError(controlDir, err) + return errors.Join(err, shutdownFile.Close(), controlDir.Close()) + } + return errors.Join(shutdownFile.Close(), controlDir.Close()) +} diff --git a/internal/commands/slices_20.go b/internal/commands/slices_20.go new file mode 100644 index 00000000..d054d1e7 --- /dev/null +++ b/internal/commands/slices_20.go @@ -0,0 +1,14 @@ +//go:build !go1.21 + +package commands + +import ( + "github.com/djdv/go-filesystem-utils/internal/command" + "golang.org/x/exp/slices" +) + +func sortCommands(commands []command.Command) { + slices.SortFunc(commands, func(a, b command.Command) bool { + return a.Name() < b.Name() + }) +} diff --git a/internal/commands/slices_21.go b/internal/commands/slices_21.go new file mode 100644 index 00000000..0865dfbb --- /dev/null +++ b/internal/commands/slices_21.go @@ -0,0 +1,22 @@ +//go:build go1.21 + +package commands + +import ( + "slices" + "strings" + + "github.com/djdv/go-filesystem-utils/internal/command" +) + +func sortCommands(commands []command.Command) { + slices.SortFunc( + commands, + func(a, b command.Command) int { + return strings.Compare( + a.Name(), + b.Name(), + ) + }, + ) +} diff --git a/internal/commands/unmount.go b/internal/commands/unmount.go index 8978e729..834eb651 100644 --- a/internal/commands/unmount.go +++ b/internal/commands/unmount.go @@ -2,77 +2,181 @@ package commands import ( "context" + "encoding/json" "errors" "flag" - "log" - "os" + "fmt" "github.com/djdv/go-filesystem-utils/internal/command" - "github.com/djdv/go-filesystem-utils/internal/daemon" - "github.com/multiformats/go-multiaddr" + "github.com/djdv/go-filesystem-utils/internal/filesystem" + p9fs "github.com/djdv/go-filesystem-utils/internal/filesystem/9p" + "github.com/djdv/go-filesystem-utils/internal/generic" + "github.com/djdv/p9/p9" ) type ( unmountSettings struct { - clientSettings all bool } + UnmountOption func(*unmountSettings) error + unmountCmdSettings struct { + clientSettings + apiOptions []UnmountOption + } + unmountCmdOption func(*unmountCmdSettings) error + unmountCmdOptions []unmountCmdOption + decodeFunc func([]byte) (string, error) + decoders map[filesystem.Host]decodeFunc ) -func (set *unmountSettings) BindFlags(flagSet *flag.FlagSet) { - set.clientSettings.BindFlags(flagSet) - flagSet.BoolVar(&set.all, "a", false, "placeholder text") +const ( + errUnmountMixed = generic.ConstError(`cannot combine "all" option with arguments`) + errUnmountEmpty = generic.ConstError(`neither parameters nor "all" option was provided`) +) + +func UnmountAll(b bool) UnmountOption { + return func(us *unmountSettings) error { + us.all = b + return nil + } } +func (uo *unmountCmdOptions) BindFlags(flagSet *flag.FlagSet) { + var clientOptions clientOptions + (&clientOptions).BindFlags(flagSet) + *uo = append(*uo, func(us *unmountCmdSettings) error { + subset, err := clientOptions.make() + if err != nil { + return err + } + us.clientSettings = subset + return nil + }) + const ( + allName = "all" + allUsage = "unmount all" + ) + flagSetFunc(flagSet, allName, allUsage, uo, + func(value bool, settings *unmountCmdSettings) error { + settings.apiOptions = append(settings.apiOptions, UnmountAll(value)) + return nil + }) +} + +func (uo unmountCmdOptions) make() (unmountCmdSettings, error) { + return makeWithOptions(uo...) +} + +// Unmount constructs the command which requests the file system service +// to undo the effects of a previous mount. func Unmount() command.Command { const ( name = "unmount" synopsis = "Unmount file systems." - usage = "Placeholder text." ) - return command.MakeCommand[*unmountSettings](name, synopsis, usage, unmountExecute) + usage := header("Unmount") + + "\n\n" + synopsis + + "\nAccepts mountpoints as arguments." + return command.MakeVariadicCommand[unmountCmdOptions](name, synopsis, usage, unmountExecute) } -func unmountExecute(ctx context.Context, set *unmountSettings) error { - var ( - err error - serviceMaddr = set.serviceMaddr - - client *daemon.Client - clientOpts []daemon.ClientOption - ) - if lazy, ok := serviceMaddr.(lazyFlag[multiaddr.Multiaddr]); ok { - if serviceMaddr, err = lazy.get(); err != nil { - return err +func unmountExecute(ctx context.Context, arguments []string, options ...unmountCmdOption) error { + settings, err := unmountCmdOptions(options).make() + if err != nil { + return err + } + const autoLaunchDaemon = false + client, err := settings.getClient(autoLaunchDaemon) + if err != nil { + return err + } + apiOptions := settings.apiOptions + if err := client.Unmount(ctx, arguments, apiOptions...); err != nil { + if errors.Is(err, errUnmountEmpty) || + errors.Is(err, errUnmountMixed) { + err = command.UsageError{Err: err} } + return errors.Join(err, client.Close()) } - if set.verbose { - // TODO: less fancy prefix and/or out+prefix from CLI flags - clientLog := log.New(os.Stdout, "⬇️ client - ", log.Lshortfile) - clientOpts = append(clientOpts, daemon.WithLogger(clientLog)) + if err := client.Close(); err != nil { + return err } + return ctx.Err() +} - // TODO: don't launch if we can't connect. - if serviceMaddr != nil { - client, err = daemon.Connect(serviceMaddr, clientOpts...) - } else { - client, err = daemon.ConnectOrLaunchLocal(clientOpts...) - } +func (c *Client) Unmount(ctx context.Context, targets []string, options ...UnmountOption) error { + settings, err := makeWithOptions(options...) if err != nil { return err } - all := set.all - if !all { - return errors.New("single targets not implemented yet, use `-a`") + var ( + unmountAll = settings.all + haveTargets = len(targets) != 0 + ) + if unmountAll && haveTargets { + return fmt.Errorf( + "%w: %v", + errUnmountMixed, targets, + ) } - unmountOpts := []daemon.UnmountOption{ - daemon.UnmountAll(all), + if !haveTargets && !unmountAll { + return errUnmountEmpty } - if err := client.Unmount(ctx, unmountOpts...); err != nil { + mounts, err := (*p9.Client)(c).Attach(mountsFileName) + if err != nil { return err } - if err := client.Close(); err != nil { - return err + if settings.all { + if err := p9fs.UnmountAll(mounts); err != nil { + err = receiveError(mounts, err) + return errors.Join(err, mounts.Close()) + } + return mounts.Close() + } + decodeFn := newDecodeTargetFunc() + if err := p9fs.UnmountTargets(mounts, targets, decodeFn); err != nil { + err = receiveError(mounts, err) + return errors.Join(err, mounts.Close()) + } + return mounts.Close() +} + +func newDecodeTargetFunc() p9fs.DecodeTargetFunc { + type makeDecoderFunc func() (filesystem.Host, decodeFunc) + var ( + decoderMakers = []makeDecoderFunc{ + unmarshalFUSE, + } + decoders = make(decoders, len(decoderMakers)) + ) + for _, decoderMaker := range decoderMakers { + host, decoder := decoderMaker() + if decoder == nil { + continue // System (likely) disabled by build constraints. + } + // No clobbering, accidental or otherwise. + if _, exists := decoders[host]; exists { + err := fmt.Errorf( + "%s decoder already registered", + host, + ) + panic(err) + } + decoders[host] = decoder + } + return func(host filesystem.Host, _ filesystem.ID, data []byte) (string, error) { + decoder, ok := decoders[host] + if !ok { + return "", fmt.Errorf("unexpected host: %v", host) + } + // Subset of struct [mountPoint]. + // Not processed by us. + var mountPoint struct { + Host json.RawMessage `json:"host"` + } + if err := json.Unmarshal(data, &mountPoint); err != nil { + return "", err + } + return decoder(mountPoint.Host) } - return ctx.Err() } diff --git a/internal/daemon/client.go b/internal/daemon/client.go deleted file mode 100644 index 21976d10..00000000 --- a/internal/daemon/client.go +++ /dev/null @@ -1,157 +0,0 @@ -package daemon - -import ( - "context" - "errors" - "fmt" - "strings" - - "github.com/hugelgupf/p9/p9" - "github.com/multiformats/go-multiaddr" - manet "github.com/multiformats/go-multiaddr/net" - "github.com/u-root/uio/ulog" -) - -type ( - nanoidGen = func() string - Client struct { - p9Client *p9.Client - log ulog.Logger - idGen nanoidGen - } -) - -func Connect(serverMaddr multiaddr.Multiaddr, options ...ClientOption) (*Client, error) { - client := new(Client) - for _, setFunc := range options { - if err := setFunc(client); err != nil { - panic(err) - } - } - - conn, err := manet.Dial(serverMaddr) - if err != nil { - return nil, err - } - - var clientOpts []p9.ClientOpt - if clientLog := client.log; clientLog != nil { - clientOpts = []p9.ClientOpt{ - p9.WithClientLogger(clientLog), - } - } - - p9Client, err := p9.NewClient(conn, clientOpts...) - if err != nil { - return nil, err - } - client.p9Client = p9Client - return client, nil -} - -func ConnectOrLaunchLocal(options ...ClientOption) (*Client, error) { - serverMaddr, err := FindLocalServer() - if err == nil { - return Connect(serverMaddr, options...) - } - if !errors.Is(err, ErrServiceNotFound) { - return nil, err - } - // TODO: const for daemon CLI cmd name - return SelfConnect([]string{"daemon"}, options...) -} - -func (c *Client) Shutdown(maddr multiaddr.Multiaddr) error { - if c.p9Client == nil { - // TODO: better message; maybe better logic? - // Can we prevent this from being possible without unexporting [Client]? - return fmt.Errorf("client is not connected") - } - - /* TODO: we're probably going to want this. But selectively. - Unmount by default, but allow socket close without unmounting via flags. - As-is you can lock yourself out of an active remote daemon - (requires sending an OS signal for graceful stop at that point). - */ - if err := c.Unmount(context.TODO(), UnmountAll(true)); err != nil { - return nil - } - - // TODO: const name in files pkg? - listenersDir, err := c.p9Client.Attach("listeners") - if err != nil { - return err - } - var names []string - multiaddr.ForEach(maddr, func(c multiaddr.Component) bool { - names = append(names, strings.Split(c.String(), "/")[1:]...) - return true - }) - tail := len(names) - 1 - _, dir, err := listenersDir.Walk(names[:tail]) - if err != nil { - return err - } - if err := dir.UnlinkAt(names[tail], 0); err != nil { - return err - } - if err := dir.Close(); err != nil { - return err - } - if err := listenersDir.Close(); err != nil { - return err - } - return nil -} - -func (c *Client) Close() error { - cl := c.p9Client - if cl == nil { - // TODO: better message; maybe better logic? - // Can we prevent this from being possible without unexporting [Client]? - return fmt.Errorf("client is not connected") - } - c.p9Client = nil - return cl.Close() -} - -// FindLocalServer searches a set of local addresses -// and returns the first dialable maddr it finds. -// Otherwise it returns [ErrServiceNotFound]. -func FindLocalServer() (multiaddr.Multiaddr, error) { - userMaddrs, err := UserServiceMaddrs() - if err != nil { - return nil, err - } - systemMaddrs, err := SystemServiceMaddrs() - if err != nil { - return nil, err - } - - var ( - localDefaults = append(userMaddrs, systemMaddrs...) - maddrStrings = make([]string, len(localDefaults)) - ) - for i, serviceMaddr := range localDefaults { - if Dialable(serviceMaddr) { - return serviceMaddr, nil - } - maddrStrings[i] = serviceMaddr.String() - } - - return nil, fmt.Errorf("%w: tried %s", - ErrServiceNotFound, strings.Join(maddrStrings, ", "), - ) -} - -// Dialable returns true if the multiaddr was dialed without error. -func Dialable(maddr multiaddr.Multiaddr) (connected bool) { - conn, err := manet.Dial(maddr) - if err == nil && conn != nil { - if err := conn.Close(); err != nil { - return // Socket is faulty, not accepting. - } - connected = true - } - return -} diff --git a/internal/daemon/launch.go b/internal/daemon/launch.go deleted file mode 100644 index 098e8e71..00000000 --- a/internal/daemon/launch.go +++ /dev/null @@ -1,57 +0,0 @@ -package daemon - -import ( - "path" - "path/filepath" - "time" - - "github.com/djdv/go-filesystem-utils/internal/generic" - "github.com/multiformats/go-multiaddr" -) - -const ( - ErrCouldNotConnect = generic.ConstError("could not connect to remote API") - ErrServiceNotFound = generic.ConstError("could not find service instance") -) - -func SelfConnect(args []string, options ...ClientOption) (*Client, error) { - const defaultDecay = 30 * time.Second - cmd, err := selfCommand(args, defaultDecay) - if err != nil { - return nil, err - } - - sio, err := setupStdioIPC(cmd) - if err != nil { - return nil, err - } - - serviceMaddr, err := startAndCommunicateWith(cmd, sio) - if err != nil { - return nil, err - } - - return Connect(serviceMaddr, options...) -} - -func servicePathsToServiceMaddrs(servicePaths ...string) ([]multiaddr.Multiaddr, error) { - var ( - serviceMaddrs = make([]multiaddr.Multiaddr, 0, len(servicePaths)) - multiaddrSet = make(map[string]struct{}, len(servicePaths)) - ) - for _, servicePath := range servicePaths { - if _, alreadySeen := multiaddrSet[servicePath]; alreadySeen { - continue // Don't return duplicates in our slice. - } else { - multiaddrSet[servicePath] = struct{}{} - } - maddrString := path.Join("/unix/", - filepath.Join(servicePath, ServerRootName, ServerName)) - serviceMaddr, err := multiaddr.NewMultiaddr(maddrString) - if err != nil { - return nil, err - } - serviceMaddrs = append(serviceMaddrs, serviceMaddr) - } - return serviceMaddrs, nil -} diff --git a/internal/daemon/mount.go b/internal/daemon/mount.go deleted file mode 100644 index 1f5be428..00000000 --- a/internal/daemon/mount.go +++ /dev/null @@ -1,107 +0,0 @@ -package daemon - -import ( - "encoding/json" - "errors" - "fmt" - "strings" - - "github.com/djdv/go-filesystem-utils/internal/command" - "github.com/djdv/go-filesystem-utils/internal/files" - "github.com/djdv/go-filesystem-utils/internal/filesystem" - "github.com/hugelgupf/p9/p9" - "github.com/hugelgupf/p9/perrors" - "github.com/jaevor/go-nanoid" - "github.com/multiformats/go-multiaddr" -) - -func (c *Client) Mount(host filesystem.API, fsid filesystem.ID, args []string, options ...MountOption) error { - set := new(mountSettings) - for _, setter := range options { - if err := setter(set); err != nil { - return err - } - } - switch host { - case filesystem.Fuse: - if len(args) == 0 { - return fmt.Errorf("%w: no mountpoints provided", command.ErrUsage) - } - mRoot, err := c.p9Client.Attach(files.MounterName) - if err != nil { - return err - } - idGen := c.idGen - if idGen == nil { - var err error - if idGen, err = nanoid.CustomASCII(base58Alphabet, idLength); err != nil { - return err - } - c.idGen = idGen - } - if err := handleFuse(mRoot, idGen, fsid, set, args); err != nil { - if errors.Is(err, perrors.EIO) { - /* TODO: Unfortunately the .L variant of 9P - uses numbers instead of strings for errors; - so we lose any additional information. - For now we'll ambiguously decorate the error. - Later we can inspect args to be more precise - (this one only applies to IPFS targets) - We can also consider setting up an extension on the daemon, - which lets us inquire deeper. - E.g. before the call, request a token, - if we get an error, send both back to the daemon. - Daemon then responds with the original error string. - This allows us to remain compliant with .L clients - without compromising on clarity in our specific client. - */ - return fmt.Errorf("%w: %s", err, "IPFS node may be unreachable?") - } - return err - } - return nil - default: - return errors.New("NIY") - } -} - -func handleFuse(mRoot p9.File, idGen nanoidGen, fsid filesystem.ID, - set *mountSettings, targets []string, -) error { - var ( - fuseName = strings.ToLower(filesystem.Fuse.String()) - fsidName = strings.ToLower(fsid.String()) - wname = []string{fuseName, fsidName} - uid = set.uid - gid = set.gid - ) - const permissions = files.S_IRWXU | files.S_IRA | files.S_IXA - idRoot, err := files.MkdirAll(mRoot, wname, permissions, uid, gid) - if err != nil { - return err - } - - name := fmt.Sprintf("%s.json", idGen()) - targetFile, _, _, err := idRoot.Create(name, p9.ReadWrite, permissions, uid, gid) - if err != nil { - return err - } - - data := struct { - ApiMaddr multiaddr.Multiaddr - Target string - }{ - Target: targets[0], // FIXME: args not handled - } - if serverMaddr := set.ipfs.nodeMaddr; serverMaddr != nil { - data.ApiMaddr = serverMaddr - } - dataBytes, err := json.Marshal(data) - if err != nil { - return err - } - if _, err := targetFile.WriteAt(dataBytes, 0); err != nil { - return err - } - return targetFile.Close() -} diff --git a/internal/daemon/options.go b/internal/daemon/options.go deleted file mode 100644 index 3e702f73..00000000 --- a/internal/daemon/options.go +++ /dev/null @@ -1,98 +0,0 @@ -package daemon - -import ( - "github.com/adrg/xdg" - "github.com/hugelgupf/p9/p9" - "github.com/multiformats/go-multiaddr" - "github.com/u-root/uio/ulog" -) - -const ( - idLength = 9 - base58Alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" -) - -type ( - ClientOption func(*Client) error - - // TODO: these should be shared literally - // I.e. 9lib.Mount and client.Mount should use the same option type/structs - MountOption func(*mountSettings) error - mountSettings struct { - ipfs struct { - nodeMaddr multiaddr.Multiaddr - } - uid p9.UID - gid p9.GID - /* - fuse struct { - // fsid filesystem.ID - // fsapi filesystem.API - uid, gid uint32 - } - */ - } - - UnmountOption func(*unmountSettings) error - unmountSettings struct { - all bool - } -) - -const ( - // TODO: del - // ServiceMaddr is the default multiaddr used by servers and clients. - // ServiceMaddr = "/ip4/127.0.0.1/tcp/564" - - // TODO: [Ame] docs. - // ServerRootName defines a name which servers and clients may use - // to refer to the service in namespace oriented APIs. - ServerRootName = "fs" - - // TODO: [Ame] docs. - // ServerName defines a name which servers and clients may use - // to form or find connections to a named server instance. - // (E.g. a Unix socket of path `.../$ServerRootName/$ServerName`.) - ServerName = "server" -) - -// TODO: [Ame] docs. -// UserServiceMaddrs returns a list of multiaddrs that servers and client commands -// may try to use when hosting or querying a user-level file system service. -func UserServiceMaddrs() ([]multiaddr.Multiaddr, error) { - return servicePathsToServiceMaddrs(xdg.StateHome, xdg.RuntimeDir) -} - -// TODO: [Ame] docs. -// SystemServiceMaddrs returns a list of multiaddrs that servers and client commands -// may try to use when hosting or querying a system-level file system service. -func SystemServiceMaddrs() ([]multiaddr.Multiaddr, error) { - return systemServiceMaddrs() // Platform specific. -} - -func AllServiceMaddrs() ([]multiaddr.Multiaddr, error) { - var ( - userMaddrs, uErr = UserServiceMaddrs() - systemMaddrs, sErr = SystemServiceMaddrs() - serviceMaddrs = append(userMaddrs, systemMaddrs...) - ) - for _, e := range []error{uErr, sErr} { - if e != nil { - return nil, e - } - } - return serviceMaddrs, nil -} - -func WithLogger(log ulog.Logger) ClientOption { - return func(c *Client) error { c.log = log; return nil } -} - -func WithIPFS(maddr multiaddr.Multiaddr) MountOption { - return func(s *mountSettings) error { s.ipfs.nodeMaddr = maddr; return nil } -} - -// TODO: shared option? -func UnmountAll(b bool) UnmountOption { - return func(us *unmountSettings) error { us.all = b; return nil } -} diff --git a/internal/daemon/proc.go b/internal/daemon/proc.go deleted file mode 100644 index 5097c78f..00000000 --- a/internal/daemon/proc.go +++ /dev/null @@ -1,98 +0,0 @@ -package daemon - -import ( - "fmt" - "io" - "os" - "os/exec" - "time" - - "github.com/djdv/go-filesystem-utils/internal/files" - "github.com/hugelgupf/p9/p9" - "github.com/multiformats/go-multiaddr" -) - -func selfCommand(args []string, exitInterval time.Duration) (*exec.Cmd, error) { - self, err := os.Executable() - if err != nil { - return nil, err - } - cmd := exec.Command(self, args...) - if exitInterval != 0 { - cmd.Args = append(cmd.Args, - fmt.Sprintf("-exit-after=%s", exitInterval), - ) - } - return cmd, nil -} - -func startAndCommunicateWith(cmd *exec.Cmd, sio stdio) (multiaddr.Multiaddr, error) { - if err := cmd.Start(); err != nil { - return nil, err - } - stdClient, err := p9.NewClient(sio) - if err != nil { - // TODO: make sure cmd is stopped / stderr is closed for sure first. - // ^This is typical, not guaranteed (proc is okay but server is not). - stderr, sioErr := io.ReadAll(sio.err) - if sioErr != nil { - return nil, sioErr - } - if len(stderr) != 0 { - err = fmt.Errorf("%w\nstderr:%s", err, stderr) - } - return nil, err - } - listenersDir, err := stdClient.Attach("listeners") // TODO: magic string -> const - if err != nil { - return nil, err - } - - maddrs, err := flattenListeners(listenersDir) - if err != nil { - return nil, err - } - - if err := stdClient.Close(); err != nil { - return nil, err - } - if err := cmd.Process.Release(); err != nil { - return nil, err - } - - return maddrs[0], nil -} - -func flattenListeners(dir p9.File) ([]multiaddr.Multiaddr, error) { - ents, err := files.ReadDir(dir) - if err != nil { - return nil, err - } - maddrs := make([]multiaddr.Multiaddr, 0, len(ents)) - for _, ent := range ents { - wnames := []string{ent.Name} - _, entFile, err := dir.Walk(wnames) // TODO: close? - if err != nil { - return nil, err - } - if ent.Type == p9.TypeDir { - submaddrs, err := flattenListeners(entFile) - if err != nil { - return nil, err - } - maddrs = append(maddrs, submaddrs...) - continue - } - - maddrBytes, err := files.ReadAll(entFile) - if err != nil { - return nil, err - } - maddr, err := multiaddr.NewMultiaddrBytes(maddrBytes) - if err != nil { - return nil, err - } - maddrs = append(maddrs, maddr) - } - return maddrs, nil -} diff --git a/internal/daemon/stdio.go b/internal/daemon/stdio.go deleted file mode 100644 index 2b80d1ae..00000000 --- a/internal/daemon/stdio.go +++ /dev/null @@ -1,43 +0,0 @@ -package daemon - -import ( - "io" - "os/exec" -) - -type stdio struct { - in io.WriteCloser - out, err io.ReadCloser -} - -func (sio stdio) Read(p []byte) (n int, err error) { - return sio.out.Read(p) -} - -func (sio stdio) Write(p []byte) (n int, err error) { - return sio.in.Write(p) -} - -func (sio stdio) Close() error { - for _, c := range []io.Closer{sio.in, sio.out, sio.err} { - if c != nil { - if err := c.Close(); err != nil { - return err - } - } - } - return nil -} - -func setupStdioIPC(cmd *exec.Cmd) (sio stdio, err error) { - if sio.in, err = cmd.StdinPipe(); err != nil { - return - } - if sio.out, err = cmd.StdoutPipe(); err != nil { - return - } - if sio.err, err = cmd.StderrPipe(); err != nil { - return - } - return -} diff --git a/internal/daemon/unmount.go b/internal/daemon/unmount.go deleted file mode 100644 index fc853f7b..00000000 --- a/internal/daemon/unmount.go +++ /dev/null @@ -1,58 +0,0 @@ -package daemon - -import ( - "context" - "errors" - - "github.com/djdv/go-filesystem-utils/internal/files" - "github.com/hugelgupf/p9/p9" -) - -// NOTE: not stable, for development/basic use only right now. -// Use 9P API directly for fine-control in the meantime. -func (c *Client) Unmount(ctx context.Context, options ...UnmountOption) error { - set := new(unmountSettings) - for _, setter := range options { - if err := setter(set); err != nil { - return err - } - } - all := set.all - if !all { - return errors.New("single targets not implemented yet, use `-a`") - } - mRoot, err := c.p9Client.Attach(files.MounterName) - if err != nil { - // TODO: if not-exist fail softer. - return err - } - if err := removeMounts(mRoot); err != nil { - return err - } - return ctx.Err() -} - -func removeMounts(fsys p9.File) error { - ents, err := files.ReadDir(fsys) - if err != nil { - return err - } - for _, ent := range ents { - switch ent.Type { - case p9.TypeRegular: - if err := fsys.UnlinkAt(ent.Name, 0); err != nil { - // TODO: continue on err? - return err - } - case p9.TypeDir: - _, sub, err := fsys.Walk([]string{ent.Name}) - if err != nil { - return err - } - if err := removeMounts(sub); err != nil { - return err - } - } - } - return nil -} diff --git a/internal/files/9p.go b/internal/files/9p.go deleted file mode 100644 index e9151247..00000000 --- a/internal/files/9p.go +++ /dev/null @@ -1,138 +0,0 @@ -package files - -import ( - "sync/atomic" - - srv9 "github.com/djdv/go-filesystem-utils/internal/net/9p" - "github.com/hugelgupf/p9/fsimpl/templatefs" - "github.com/hugelgupf/p9/p9" -) - -type ( - NineDir struct { - p9.File - path *atomic.Uint64 - } - nineInterfaceFile struct { - templatefs.NoopFile - // nineServer *p9.Server // TODO: use our shutdown-able server implementation instead. - nineServer *srv9.Server // TODO: use our shutdown-able server implementation instead. - metadata - } -) - -func NewNineDir(options ...NineOption) (p9.QID, *NineDir) { - panic("NIY") - /* - var ( - - server = &srv9.Server{ - // TODO: is a proper srv9.New func possible with our callbacks? - ListenerManager: new(net.ListenerManager), - } - handleListener = func(listener manet.Listener) { - go func() { - defer listenersWg.Done() - for err := range server.Serve(ctx, listener) { - select { - case serveErrs <- err: - case <-ctx.Done(): - return - } - } - srvLog.Print("done listening on: ", listener.Multiaddr()) - }() - } - - qid, dir = NewListener(handleListener, options...) - fsys = &NineDir{File: dir, path: dir.path} - - ) - // TODO: funcopt needs RDev setter? SetAttr doesn't expose it, - // and neither do some of our types. - // dir.RDev = p9.Dev(filesystem.Plan9Protocol) - return qid, fsys - */ -} - -/* Code circa 2017 - deprecated by netsys/listener -func listen9(ctx context.Context, maddr string, server *p9.Server) (serverRef, error) { - // parse and listen on the address - ma, err := multiaddr.NewMultiaddr(maddr) - if err != nil { - return serverRef{}, err - } - - mListener, err := manet.Listen(ma) - if err != nil { - return serverRef{}, err - } - - // construct the actual reference - // NOTE: [async] - // `srvErr` will be set only once - // The `err` function checks a "was set" boolean to assure the `error` is fully assigned, before trying to return it - // This is because `ref.err` will be called without synchronization, and could cause a read/write collision on an `error` type - // We don't have to care about a bool's value being fully written or not, but a partially written `error` is an node with an arbitrary value - // `decRef` has synchronization, so it may use `srvErr` directly (after syncing) - // The counter however, will only ever be manipulated while the reference table is in a locked state - // (if this changes, the counter should be made atomic) - var ( - srvErr error - srvErrWasSet bool - srvWg sync.WaitGroup // done when the server has stopped serving - count uint - ) - - serverRef := serverRef{ - Listener: mListener, - incRef: func() { count++ }, - err: func() error { - if srvErrWasSet { - return srvErr - } - return nil - }, - decRef: func() error { - count-- - if count == 0 { - lstErr := mListener.Close() // will trigger the server to stop - srvWg.Wait() // wait for the server to assign its error - - if srvErr == nil && lstErr != nil { // server didn't encounter an error, but the listener did - return lstErr - } - - err := srvErr // server encountered an error - if lstErr != nil { // append the listener error if it encountered one too - err = fmt.Errorf("%w; additionally the listener encountered an error on `Close`: %s", err, lstErr) - } - - return err - } - return nil - }, - } - - // launch the resource server instance in the background - // until either an error is encountered, or the listener is closed (which forces an "accept" error) - srvWg.Add(1) - go func() { - defer srvWg.Done() - if err := server.Serve(manet.NetListener(mListener)); err != nil { - if ctx.Err() != nil { - var opErr *gonet.OpError - if errors.As(err, &opErr) && opErr.Op != "accept" { - err = nil // drop this since it's expected in this case - } - // note that we don't filter "accept" errors when the context has not been canceled - // as that is not expected to happen - } - srvErr = err - srvErrWasSet = true // async shenanigans; see note on declaration - } - }() - - return serverRef, nil -} -*/ diff --git a/internal/files/directory.go b/internal/files/directory.go deleted file mode 100644 index 238f3dea..00000000 --- a/internal/files/directory.go +++ /dev/null @@ -1,190 +0,0 @@ -package files - -import ( - "fmt" - - "github.com/djdv/go-filesystem-utils/internal/generic" - "github.com/hugelgupf/p9/p9" - "github.com/hugelgupf/p9/perrors" -) - -const errNotDirectory = generic.ConstError("type does not implement directory interface extensions") - -type ( - directory interface { - p9.File - fileTable - placeholderName - // entry(name string) (p9.File, error) - // TODO: can we eliminate this? - path() ninePath - // - } - Directory struct { - fileTable - File - } - ephemeralDir struct { - *Directory - } -) - -func assertDirectory(dir p9.File) (directory, error) { - typedDir, ok := dir.(directory) - if !ok { - err := fmt.Errorf("%T: %w", dir, errNotDirectory) - return nil, err - } - return typedDir, nil -} - -func NewDirectory(options ...DirectoryOption) (p9.QID, *Directory) { - var settings directorySettings - if err := parseOptions(&settings, options...); err != nil { - panic(err) - } - var ( - metadata = settings.metadata - withTimestamps = settings.withTimestamps - ) - initMetadata(&metadata, p9.ModeDirectory, withTimestamps) - return *metadata.QID, &Directory{ - fileTable: newFileTable(), - File: File{ - metadata: metadata, - link: settings.linkSettings, - }, - } -} - -func newEphemeralDirectory(options ...DirectoryOption) (_ p9.QID, directory *ephemeralDir) { - qid, fsys := NewDirectory(options...) - if parent := fsys.parent; parent == nil { - panic("parent file missing, dir unlinkable") // TODO: better message - } - return qid, &ephemeralDir{Directory: fsys} -} - -func (dir *Directory) Attach() (p9.File, error) { return dir, nil } - -func (dir *Directory) clone(withQID bool) (qs []p9.QID, clone *Directory, _ error) { - clone = &Directory{ - fileTable: dir.fileTable, - File: File{ - metadata: dir.metadata, - link: dir.link, - }, - } - if withQID { - qs = []p9.QID{*clone.QID} - } - return -} - -func (dir *Directory) Walk(names []string) ([]p9.QID, p9.File, error) { - return walk[*Directory](dir, names...) -} - -func (dir *Directory) Open(mode p9.OpenFlags) (p9.QID, ioUnit, error) { - if mode.Mode() != p9.ReadOnly { - // TODO: [spec] correct evalue? - return p9.QID{}, noIOUnit, perrors.EINVAL - } - if dir.fidOpened() { - return p9.QID{}, noIOUnit, perrors.EBADF - } - dir.openFlag = true - return *dir.QID, noIOUnit, nil -} - -func (dir *Directory) Link(file p9.File, name string) error { - if !dir.exclusiveStore(name, file) { - return perrors.EEXIST // TODO: spec; evalue - } - return nil -} - -func (dir *Directory) UnlinkAt(name string, flags uint32) error { - if !dir.delete(name) { - return perrors.ENOENT // TODO: spec; evalue - } - return nil -} - -func (dir *Directory) Mkdir(name string, permissions p9.FileMode, _ p9.UID, gid p9.GID) (p9.QID, error) { - if _, exists := dir.load(name); exists { - return p9.QID{}, perrors.EEXIST - } - directoryOptions := []DirectoryOption{ - WithSuboptions[DirectoryOption]( - WithPath(dir.ninePath), - WithBaseAttr(&p9.Attr{ - Mode: mkdirMask(permissions), - UID: dir.UID, - GID: gid, - }), - WithAttrTimestamps(true), - ), - WithSuboptions[DirectoryOption]( - WithParent(dir, name), - ), - } - qid, newDir := NewDirectory(directoryOptions...) - return qid, dir.Link(newDir, name) -} - -func (dir *Directory) Readdir(offset uint64, count uint32) (p9.Dirents, error) { - return dir.to9Ents(offset, count) -} - -func rename(oldDir, newDir, file p9.File, oldName, newName string) error { - if oldDir == nil || oldName == "" { - return perrors.ENOENT // TODO: [spec] check if this is the right evalue to use - } - // TODO: attempt rollback on error - if err := newDir.Link(file, newName); err != nil { - return err - } - const flags = 0 - return oldDir.UnlinkAt(oldName, flags) -} - -func (dir *Directory) Rename(newDir p9.File, newName string) error { - var ( - parent = dir.link.parent - oldName = dir.link.name - ) - return rename(parent, newDir, dir, oldName, newName) -} - -func (dir *Directory) RenameAt(oldName string, newDir p9.File, newName string) error { - parent := dir.link.parent - return rename(parent, newDir, dir, oldName, newName) -} - -func (eDir *ephemeralDir) clone(withQID bool) ([]p9.QID, *ephemeralDir, error) { - qids, dir, err := eDir.Directory.clone(withQID) - if err != nil { - return nil, nil, err - } - return qids, &ephemeralDir{Directory: dir}, nil -} - -func (eDir *ephemeralDir) UnlinkAt(name string, flags uint32) error { - if err := eDir.Directory.UnlinkAt(name, flags); err != nil { - return err - } - ents, err := ReadDir(eDir.Directory) - if err != nil { - return err - } - if len(ents) == 0 { - var ( - link = eDir.link - parent = link.parent - self = link.name - ) - return parent.UnlinkAt(self, flags) - } - return nil -} diff --git a/internal/files/file.go b/internal/files/file.go deleted file mode 100644 index c5f30b80..00000000 --- a/internal/files/file.go +++ /dev/null @@ -1,31 +0,0 @@ -package files - -import ( - "sync" - - "github.com/hugelgupf/p9/fsimpl/templatefs" - "github.com/hugelgupf/p9/p9" -) - -type ( - noopFile = templatefs.NoopFile - link = linkSettings - file = p9.File - File struct { - noopFile - metadata - link - openFlag - } - - // TODO: [7dd5513d-4991-46c9-8632-fc36475e88a8] This has shown up again. - deferMutex struct{ sync.Mutex } -) - -func (fi *File) SetAttr(valid p9.SetAttrMask, attr p9.SetAttr) error { - return fi.metadata.SetAttr(valid, attr) -} - -func (fi *File) GetAttr(req p9.AttrMask) (p9.QID, p9.AttrMask, p9.Attr, error) { - return fi.metadata.GetAttr(req) -} diff --git a/internal/files/fsid.go b/internal/files/fsid.go deleted file mode 100644 index 614ade2e..00000000 --- a/internal/files/fsid.go +++ /dev/null @@ -1,179 +0,0 @@ -package files - -import ( - "errors" - "io" - "io/fs" - "sync/atomic" - - "github.com/djdv/go-filesystem-utils/internal/filesystem" - "github.com/hugelgupf/p9/p9" - "github.com/hugelgupf/p9/perrors" -) - -type ( - detachFunc = func() error // TODO: placehold name. - mountFunc = func(_ fs.FS, target string) (io.Closer, error) // TODO: placeholder name. - - detacher interface { - Detach() error - } - - FSIDDir struct { - directory - mount mountFunc // TODO: placeholder name - path *atomic.Uint64 - cleanupEmpties bool - } -) - -func NewFSIDDir(fsid filesystem.ID, mountFn mountFunc, options ...FSIDOption) (p9.QID, *FSIDDir) { - var settings fsidSettings - if err := parseOptions(&settings, options...); err != nil { - panic(err) - } - // FIXME: Attr is nil if no base option provided - settings.RDev = p9.Dev(fsid) - var ( - qid p9.QID - fsys directory - unlinkSelf = settings.cleanupSelf - directoryOptions = []DirectoryOption{ - WithSuboptions[DirectoryOption](settings.metaSettings.asOptions()...), - WithSuboptions[DirectoryOption](settings.linkSettings.asOptions()...), - } - ) - if unlinkSelf { - qid, fsys = newEphemeralDirectory(directoryOptions...) - } else { - qid, fsys = NewDirectory(directoryOptions...) - } - return qid, &FSIDDir{ - path: settings.ninePath, - directory: fsys, - cleanupEmpties: settings.cleanupElements, - mount: mountFn, - } -} - -func (fsi *FSIDDir) clone(withQID bool) ([]p9.QID, *FSIDDir, error) { - var wnames []string - if withQID { - wnames = []string{selfWName} - } - qids, dirClone, err := fsi.directory.Walk(wnames) - if err != nil { - return nil, nil, err - } - typedDir, err := assertDirectory(dirClone) - if err != nil { - return nil, nil, err - } - newDir := &FSIDDir{ - directory: typedDir, - path: fsi.path, - } - return qids, newDir, nil -} - -func (fsi *FSIDDir) Walk(names []string) ([]p9.QID, p9.File, error) { - return walk[*FSIDDir](fsi, names...) -} - -// TODO: stub out [Link] too? -func (dir *FSIDDir) Mkdir(name string, permissions p9.FileMode, _ p9.UID, gid p9.GID) (p9.QID, error) { - return p9.QID{}, perrors.ENOSYS -} - -func (mn *FSIDDir) Create(name string, flags p9.OpenFlags, permissions p9.FileMode, - _ p9.UID, gid p9.GID, -) (p9.File, p9.QID, uint32, error) { - if qid, err := mn.Mknod(name, permissions, 0, 0, 0, gid); err != nil { - return nil, qid, 0, err - } - _, mf, err := mn.directory.Walk([]string{name}) - if err != nil { - return nil, p9.QID{}, 0, err - } - // TODO: clone here? - // FIXME: Create makes+stores this file ptr, we flag it as opened - // that is never cleared - // walk should return a clone always? we should just unflag on close? - // TODO: review ^ for now we're going with the former. - // ^ read Chris's note on this too. - _, clone, err := mf.Walk(nil) - if err != nil { - return nil, p9.QID{}, 0, err - } - if err := mf.Close(); err != nil { - // TODO: close the clone here too. Merge any errors. - return nil, p9.QID{}, 0, err - } - qid, n, err := clone.Open(flags) - if err != nil { - return nil, p9.QID{}, 0, err - } - return clone, qid, n, nil -} - -func (mn *FSIDDir) Mknod(name string, mode p9.FileMode, - major uint32, minor uint32, _ p9.UID, gid p9.GID, -) (p9.QID, error) { - var ( - want = p9.AttrMask{UID: true, RDev: true} - required = p9.AttrMask{RDev: true} - attr, err = maybeGetAttrs(mn.directory, want, required) - ) - if err != nil { - return p9.QID{}, err - } - // TODO: spec check; is mknod supposed to inherit permissions or only use the supplied? - attr.Mode = p9.ModeRegular | mknodMask(mode) - attr.GID = gid - switch fsid := filesystem.ID(attr.RDev); fsid { - case filesystem.IPFS, filesystem.IPFSPins, - filesystem.IPNS, filesystem.IPFSKeys: - var ( - metaOptions = []MetaOption{ - WithPath(mn.path), - WithBaseAttr(attr), - WithAttrTimestamps(true), - } - linkOptions = []LinkOption{ - WithParent(mn, name), - } - ipfsOptions = []IPFSOption{ - WithSuboptions[IPFSOption](metaOptions...), - WithSuboptions[IPFSOption](linkOptions...), - } - ) - qid, ipfsFile, err := newIPFSMounter(fsid, mn.mount, ipfsOptions...) - if err != nil { - return p9.QID{}, err - } - return qid, mn.Link(ipfsFile, name) - default: - return p9.QID{}, errors.New("unexpected fsid") // TODO: real error - } -} - -func (mn *FSIDDir) UnlinkAt(name string, flags uint32) error { - var ( - dir = mn.directory - _, file, err = dir.Walk([]string{name}) - ) - if err != nil { - return err - } - - // TODO: we still need to {close | unlink} when encountering an error - // after whichever side we decide to do first. - - if err := dir.UnlinkAt(name, flags); err != nil { - return err - } - if target, ok := file.(detacher); ok { - return target.Detach() - } - return nil -} diff --git a/internal/files/fuse.go b/internal/files/fuse.go deleted file mode 100644 index 9363df98..00000000 --- a/internal/files/fuse.go +++ /dev/null @@ -1,78 +0,0 @@ -package files - -import ( - "sync/atomic" - - "github.com/djdv/go-filesystem-utils/internal/filesystem" - "github.com/djdv/go-filesystem-utils/internal/filesystem/cgofuse" - "github.com/hugelgupf/p9/p9" -) - -type ( - FuseDir struct { - directory - path *atomic.Uint64 - cleanupEmpties bool - } -) - -func NewFuseDir(options ...FuseOption) (p9.QID, *FuseDir) { - var settings fuseSettings - if err := parseOptions(&settings, options...); err != nil { - panic(err) - } - settings.RDev = p9.Dev(filesystem.Fuse) - var ( - qid p9.QID - fsys directory - unlinkSelf = settings.cleanupSelf - directoryOptions = []DirectoryOption{ - WithSuboptions[DirectoryOption](settings.metaSettings.asOptions()...), - WithSuboptions[DirectoryOption](settings.linkSettings.asOptions()...), - } - ) - if unlinkSelf { - qid, fsys = newEphemeralDirectory(directoryOptions...) - } else { - qid, fsys = NewDirectory(directoryOptions...) - } - return qid, &FuseDir{ - path: settings.ninePath, - directory: fsys, - cleanupEmpties: settings.cleanupElements, - } -} - -func (dir *FuseDir) Mkdir(name string, permissions p9.FileMode, _ p9.UID, gid p9.GID) (p9.QID, error) { - fsid, err := filesystem.ParseID(name) - if err != nil { - return p9.QID{}, err - } - attr, err := mkdirInherit(dir, permissions, gid) - if err != nil { - return p9.QID{}, err - } - var ( - metaOptions = []MetaOption{ - WithPath(dir.path), - WithBaseAttr(attr), - WithAttrTimestamps(true), - } - linkOptions = []LinkOption{ - WithParent(dir, name), - } - generatorOptions []GeneratorOption - ) - if dir.cleanupEmpties { - generatorOptions = append(generatorOptions, - CleanupSelf(true), - CleanupEmpties(true), - ) - } - qid, fsiDir := NewFSIDDir(fsid, cgofuse.MountFuse, - WithSuboptions[FSIDOption](metaOptions...), - WithSuboptions[FSIDOption](linkOptions...), - WithSuboptions[FSIDOption](generatorOptions...), - ) - return qid, dir.Link(fsiDir, name) -} diff --git a/internal/files/ipfs.go b/internal/files/ipfs.go deleted file mode 100644 index 50505237..00000000 --- a/internal/files/ipfs.go +++ /dev/null @@ -1,269 +0,0 @@ -package files - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io/fs" - "net" - "net/http" - "sync" - "time" - - "github.com/djdv/go-filesystem-utils/internal/filesystem" - "github.com/hugelgupf/p9/p9" - "github.com/hugelgupf/p9/perrors" - httpapi "github.com/ipfs/go-ipfs-http-client" - "github.com/multiformats/go-multiaddr" - madns "github.com/multiformats/go-multiaddr-dns" - manet "github.com/multiformats/go-multiaddr/net" -) - -type ( - ipfsMounter struct { - File - fsid filesystem.ID - dataMu sync.Locker - ipfsDataBuffer - *ipfsMountData - // TODO: unmount should have its own mutex, and probably abstraction. - unmount *detachFunc // NOTE: Shared R/W access across all FIDs. - mount mountFunc // TODO: placeholder name. - } - ipfsMountData struct { - ApiMaddr ipfsAPIMultiaddr - Target string - } - ipfsDataBuffer struct { - write *bytes.Buffer - read *bytes.Reader - } -) - -func newIPFSMounter(fsid filesystem.ID, mountFn mountFunc, options ...IPFSOption) (p9.QID, *ipfsMounter, error) { - var settings ipfsSettings - if err := parseOptions(&settings, options...); err != nil { - return p9.QID{}, nil, err - } - var ( - metadata = settings.metadata - withTimestamps = settings.withTimestamps - ) - initMetadata(&metadata, p9.ModeRegular, withTimestamps) - return *metadata.QID, &ipfsMounter{ - File: File{ - metadata: metadata, - link: settings.linkSettings, - }, - fsid: fsid, - dataMu: new(sync.Mutex), - ipfsMountData: new(ipfsMountData), - mount: mountFn, - unmount: new(detachFunc), - }, nil -} - -func (im *ipfsMounter) clone(withQID bool) ([]p9.QID, *ipfsMounter, error) { - var ( - qids []p9.QID - // TODO: audit; struct changed, what fields specifically need to be copied. - newIt = &ipfsMounter{ - // File: im.File, - File: File{ - metadata: im.File.metadata, - link: im.File.link, - }, - fsid: im.fsid, - // TODO: can we wrap this up into a (general) type? *bufferedWriterSync? - dataMu: im.dataMu, - ipfsDataBuffer: im.ipfsDataBuffer, - ipfsMountData: im.ipfsMountData, - // - mount: im.mount, - unmount: im.unmount, - } - ) - if withQID { - qids = []p9.QID{*newIt.QID} - } - return qids, newIt, nil -} - -func (im *ipfsMounter) Walk(names []string) ([]p9.QID, p9.File, error) { - return walk[*ipfsMounter](im, names...) -} - -func (im *ipfsMounter) Open(mode p9.OpenFlags) (p9.QID, ioUnit, error) { - if im.fidOpened() { - return p9.QID{}, noIOUnit, perrors.EBADF - } - im.File.openFlag = true - return *im.QID, noIOUnit, nil -} - -func (im *ipfsMounter) WriteAt(p []byte, offset int64) (int, error) { - im.dataMu.Lock() - defer im.dataMu.Unlock() - writer := im.write - if writer == nil { - writer = new(bytes.Buffer) - im.write = writer - } - if dLen := writer.Len(); offset != int64(dLen) { - err := fmt.Errorf("only contiguous writes are currently supported") - return -1, err - } - return writer.Write(p) -} - -func (im *ipfsMounter) ReadAt(p []byte, offset int64) (int, error) { - im.dataMu.Lock() - defer im.dataMu.Unlock() - reader := im.read - if reader == nil { - b, err := json.Marshal(im.ipfsMountData) - if err != nil { - return -1, err - } - reader = bytes.NewReader(b) - im.read = reader - } - return reader.ReadAt(p, offset) -} - -func (im *ipfsMounter) Close() error { - im.dataMu.Lock() - defer im.dataMu.Unlock() - if writer := im.write; writer != nil && - writer.Len() != 0 { - defer writer.Reset() // TODO: review where this should happen. - var ( - targetData = writer.Bytes() - targetPtr = im.ipfsMountData - err = json.Unmarshal(targetData, targetPtr) - ) - if err != nil { - return err - } - if reader := im.read; reader != nil { // TODO export to method [invalidateReader]/[updateReader] or whatever. Data changed, so we re-encode and reset the reader. - b, err := json.Marshal(targetPtr) - if err != nil { - return err - } - im.read = bytes.NewReader(b) - } - fsid := im.fsid - goFS, err := ipfsToGoFS(fsid, targetPtr.ApiMaddr.Multiaddr) - if err != nil { - return err - } - // FIXME: ping IPFS node here. If it's not alive, don't even try to mount it. - // ^ Don't do this; file system calls should not depend on connection state - // (system-wide, per-call may error, but not total failure). - closer, err := im.mount(goFS, targetPtr.Target) - if err != nil { - // TODO: We do this for now in case the CLI call fails - // but should handle this differently for API callers. - // Add some flag like `unlink-on-failure` or something. - // (The reason for this is so the background process doesn't hang around forever - // thinking it has an active mountpoint when it doesn't) - if parent := im.File.link.parent; parent != nil { - // TODO: We'll need to handle the error too. - parent.UnlinkAt(im.File.link.name, 0) - } - // TODO: error format - return fmt.Errorf("%w: %s", perrors.EIO, err) - } - *im.unmount = closer.Close - } - return nil -} - -func (im *ipfsMounter) Detach() error { - detach := *im.unmount - if detach == nil { - return errors.New("not attached") // TODO: error message+value - } - return detach() -} - -func ipfsToGoFS(fsid filesystem.ID, ipfsMaddr multiaddr.Multiaddr) (fs.FS, error) { - client, err := ipfsClient(ipfsMaddr) - if err != nil { - return nil, err - } - // TODO [de-dupe]: convert PinFS to fallthrough to IPFS if possible. - // Both need a client+IPFS-FS. - switch fsid { // TODO: add all cases - case filesystem.IPFS, - filesystem.IPNS: - return filesystem.NewIPFS(client, fsid), nil - case filesystem.IPFSPins: - ipfs := filesystem.NewIPFS(client, filesystem.IPFS) - return filesystem.NewPinFS(client.Pin(), - filesystem.WithIPFS[filesystem.PinfsOption](ipfs), - ), nil - case filesystem.IPFSKeys: - ipns := filesystem.NewIPFS(client, filesystem.IPNS) - return filesystem.NewKeyFS(client.Key(), - filesystem.WithIPNS[filesystem.KeyfsOption](ipns), - ), nil - default: - return nil, fmt.Errorf("%s has no handler", fsid) - } -} - -func ipfsClient(apiMaddr multiaddr.Multiaddr) (*httpapi.HttpApi, error) { - ctx, cancelFunc := context.WithTimeout(context.Background(), 10*time.Second) - defer cancelFunc() - resolvedMaddr, err := resolveMaddr(ctx, apiMaddr) - if err != nil { - return nil, err - } - - // TODO: I think the upstream package needs a patch to handle this internally. - // we'll hack around it for now. Investigate later. - // (When trying to use a unix socket for the IPFS maddr - // the client returned from httpapi.NewAPI will complain on requests - forgot to copy the error lol) - network, dialHost, err := manet.DialArgs(resolvedMaddr) - if err != nil { - return nil, err - } - switch network { - default: - return httpapi.NewApi(resolvedMaddr) - case "unix": - // TODO: consider patching cmds-lib - // we want to use the URL scheme "http+unix" - // as-is, it prefixes the value to be parsed by pkg `url` as "http://http+unix://" - var ( - clientHost = "http://file-system-socket" // TODO: const + needs real name/value - netDialer = new(net.Dialer) - ) - return httpapi.NewURLApiWithClient(clientHost, &http.Client{ - Transport: &http.Transport{ - DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { - return netDialer.DialContext(ctx, network, dialHost) - }, - }, - }) - } -} - -func resolveMaddr(ctx context.Context, addr multiaddr.Multiaddr) (multiaddr.Multiaddr, error) { - ctx, cancelFunc := context.WithTimeout(ctx, 10*time.Second) - defer cancelFunc() - - addrs, err := madns.DefaultResolver.Resolve(ctx, addr) - if err != nil { - return nil, err - } - - if len(addrs) == 0 { - return nil, errors.New("non-resolvable API endpoint") - } - - return addrs[0], nil -} diff --git a/internal/files/listener.go b/internal/files/listener.go deleted file mode 100644 index f3089889..00000000 --- a/internal/files/listener.go +++ /dev/null @@ -1,457 +0,0 @@ -package files - -import ( - "bytes" - "fmt" - "os" - "path" - "path/filepath" - "runtime" - "strings" - - "github.com/hugelgupf/p9/fsimpl/templatefs" - "github.com/hugelgupf/p9/p9" - "github.com/hugelgupf/p9/perrors" - "github.com/multiformats/go-multiaddr" - manet "github.com/multiformats/go-multiaddr/net" -) - -type ( - ListenerCallback = func(manet.Listener) - Listener struct { - directory - path ninePath - mknodCallback ListenerCallback - prefix multiaddr.Multiaddr - protocol string - maddrPath []string - cleanupEmpties bool - } - listenerDir interface { - p9.File - fileTable - } - listenerFile struct { - templatefs.NoopFile - metadata - Listener manet.Listener - // TODO: proper file I/O; methods are hacky right now - // maddrReader *bytes.Reader - openFlag - } - listenerUDSWrapper struct { - manet.Listener - closeFunc func() error - } -) - -func NewListener(callback ListenerCallback, options ...ListenerOption) (p9.QID, *Listener) { - var settings listenerSettings - if err := parseOptions(&settings, options...); err != nil { - panic(err) - } - // TODO: rdev value? - var ( - qid p9.QID - fsys directory - unlinkSelf = settings.cleanupSelf - directoryOptions = []DirectoryOption{ - WithSuboptions[DirectoryOption](settings.metaSettings.asOptions()...), - WithSuboptions[DirectoryOption](settings.linkSettings.asOptions()...), - } - ) - if unlinkSelf { - qid, fsys = newEphemeralDirectory(directoryOptions...) - } else { - qid, fsys = NewDirectory(directoryOptions...) - } - return qid, &Listener{ - path: settings.ninePath, - directory: fsys, - mknodCallback: callback, - cleanupEmpties: settings.cleanupElements, - } -} - -func (ld *Listener) clone(withQID bool) ([]p9.QID, *Listener, error) { - var wnames []string - if withQID { - wnames = []string{selfWName} - } - qids, dirClone, err := ld.directory.Walk(wnames) - if err != nil { - return nil, nil, err - } - typedDir, err := assertDirectory(dirClone) - if err != nil { - return nil, nil, err - } - newDir := &Listener{ - directory: typedDir, - path: ld.path, - mknodCallback: ld.mknodCallback, - prefix: ld.prefix, - protocol: ld.protocol, - } - return qids, newDir, nil -} - -func (ld *Listener) Walk(names []string) ([]p9.QID, p9.File, error) { - return walk[*Listener](ld, names...) -} - -func (ld *Listener) fidOpened() bool { return false } // TODO need to store state or read &.dir's - -func (ld *Listener) Mkdir(name string, permissions p9.FileMode, _ p9.UID, gid p9.GID) (p9.QID, error) { - var ( - prefix = ld.prefix - protocolName = ld.protocol - maddrPath = ld.maddrPath - ) - // TODO: try to make this less gross / split up. - if protocolName == "" { - if err := validateProtocol(name); err != nil { - // TODO: error value - return p9.QID{}, fmt.Errorf("%w - %s", perrors.EIO, err) - } - protocolName = name - } else { - // TODO: [1413c980-2a65-4144-a679-7be1b77f01e3] generalize. - // multiaddr pkg should have an index with a .Path flag set - // for ones that need this behaviour. Right now hardcoding UDS support only. - if protocolName == "unix" { - maddrPath = append(maddrPath, name) - } else { - var err error - if prefix, err = appendMaddr(prefix, protocolName, name); err != nil { - // TODO: error value - return p9.QID{}, fmt.Errorf("%w - %s", perrors.EIO, err) - } - protocolName = "" - } - } - attr, err := mkdirInherit(ld, permissions, gid) - if err != nil { - return p9.QID{}, err - } - var ( - qid p9.QID - fsys directory - directoryOptions = []DirectoryOption{ - WithSuboptions[DirectoryOption]( - WithPath(ld.path), - WithBaseAttr(attr), - WithAttrTimestamps(true), - ), - WithSuboptions[DirectoryOption]( - WithParent(ld, name), - ), - } - ) - if ld.cleanupEmpties { - qid, fsys = newEphemeralDirectory(directoryOptions...) - } else { - qid, fsys = NewDirectory(directoryOptions...) - } - // TODO: Maybe (re)add internal options and call the constructor here - // (instead of using a literal). - // withPrefix, withProtocol, ... - newDir := &Listener{ - path: ld.path, - directory: fsys, - mknodCallback: ld.mknodCallback, - prefix: prefix, - protocol: protocolName, - maddrPath: maddrPath, - cleanupEmpties: ld.cleanupEmpties, - } - return qid, ld.Link(newDir, name) -} - -func validateProtocol(name string) error { - protocol := multiaddr.ProtocolWithName(name) - if protocol.Code == 0 { - return fmt.Errorf("\"%s\" not a valid protocol", name) - } - return nil -} - -func appendMaddr(prefix multiaddr.Multiaddr, protocol, value string) (multiaddr.Multiaddr, error) { - component, err := multiaddr.NewComponent(protocol, value) - if err != nil { - return nil, err - } - if prefix != nil { - return prefix.Encapsulate(component), nil - } - return component, nil -} - -func (ld *Listener) Create(name string, flags p9.OpenFlags, - permissions p9.FileMode, uid p9.UID, gid p9.GID, -) (p9.File, p9.QID, uint32, error) { - if qid, err := ld.Mknod(name, permissions|p9.ModeRegular, 0, 0, uid, gid); err != nil { - return nil, qid, 0, err - } - _, lf, err := ld.Walk([]string{name}) - if err != nil { - return nil, p9.QID{}, 0, err - } - qid, n, err := lf.Open(flags) - if err != nil { - return nil, p9.QID{}, 0, err - } - return lf, qid, n, nil -} - -func (ld *Listener) Mknod(name string, mode p9.FileMode, - major uint32, minor uint32, _ p9.UID, gid p9.GID, -) (p9.QID, error) { - callback := ld.mknodCallback - if callback == nil { - return p9.QID{}, perrors.ENOSYS - } - - protocol := ld.protocol - // TODO: [1413c980-2a65-4144-a679-7be1b77f01e3] - if protocol == "unix" { - name = path.Join(append(ld.maddrPath, name)...) - } - component, err := multiaddr.NewComponent(protocol, name) - if err != nil { - return p9.QID{}, err - } - var maddr multiaddr.Multiaddr = component - if prefix := ld.prefix; prefix != nil { - maddr = prefix.Encapsulate(component) - } - listener, err := listen(maddr) - if err != nil { - return p9.QID{}, err - } - defer callback(listener) - // TODO: we need to close the listener, - // in case of file system error before return. - // (Extending the defer to check nrErr, would probably be easiest.) - attr, err := mknodInherit(ld, mode, gid) - if err != nil { - return p9.QID{}, err - } - listenerFile := ld.makeListenerFile(listener, name, attr) - return *listenerFile.QID, ld.Link(listenerFile, name) -} - -func (ld *Listener) makeListenerFile(listener manet.Listener, - name string, attr *p9.Attr, -) *listenerFile { - attr.Size = uint64(len(listener.Multiaddr().Bytes())) - listenerFile := &listenerFile{ - metadata: metadata{ - ninePath: ld.path, - Attr: attr, - }, - Listener: listener, - } - const withTimestamps = true - initMetadata(&listenerFile.metadata, p9.ModeRegular, withTimestamps) - return listenerFile -} - -func (ld *Listener) Listen(maddr multiaddr.Multiaddr) (manet.Listener, error) { - var ( - _, names = splitMaddr(maddr) - components = names[:len(names)-1] - socket = names[len(names)-1] - want = p9.AttrMask{ - Mode: true, - UID: true, - GID: true, - } - required = p9.AttrMask{Mode: true} - attr, err = maybeGetAttrs(ld.directory, want, required) - ) - if err != nil { - return nil, err - } - - var ( - basePermissions = attr.Mode.Permissions() - dirPermissions = mkdirMask(basePermissions) - sockPermissions = socketMask(basePermissions) - uid = attr.UID - gid = attr.GID - ) - protocolDir, err := MkdirAll(ld, components, dirPermissions, uid, gid) - if err != nil { - return nil, err - } - - // TODO: Close the listener in the event of an FS/link err? - listener, err := listen(maddr) - if err != nil { - return nil, err - } - - attr.Mode = sockPermissions - listenerFile := ld.makeListenerFile(listener, socket, attr) - return listener, protocolDir.Link(listenerFile, socket) -} - -// TODO: split listen up into 3 phases -// listen; mkfile; linkfile. Listen and Mknod call all 3, -// but only mknod inserts a callback between the last phase. -func listen(maddr multiaddr.Multiaddr) (manet.Listener, error) { - var ( - err error - cleanup func() error - ) - for _, protocol := range maddr.Protocols() { - if protocol.Code == multiaddr.P_UNIX { - var udsPath string - if udsPath, err = maddr.ValueForProtocol(protocol.Code); err != nil { - break - } - if runtime.GOOS == "windows" { // `/C:\path` -> `C:\path` - udsPath = strings.TrimPrefix(udsPath, `/`) - } - socketDir := filepath.Dir(udsPath) - - // TODO: permission check - const permissions = 0o775 - if err = os.Mkdir(socketDir, permissions); err != nil { - break - } - cleanup = func() error { - return os.Remove(socketDir) - } - break - } - } - if err != nil { - return nil, err - } - listener, err := manet.Listen(maddr) - if err != nil { - if cleanup != nil { - if cErr := cleanup(); cErr != nil { - return nil, cErr - } - } - return nil, err - } - if cleanup != nil { - return &listenerUDSWrapper{ - Listener: listener, - closeFunc: cleanup, - }, nil - } - return listener, nil -} - -func splitMaddr(maddr multiaddr.Multiaddr) (components []*multiaddr.Component, names []string) { - multiaddr.ForEach(maddr, func(c multiaddr.Component) bool { - components = append(components, &c) - names = append(names, strings.Split(c.String(), "/")[1:]...) - return true - }) - return -} - -// getFirstUnixSocketPath returns the path -// of the first Unix domain socket within the multiaddr (if any) -func getFirstUnixSocketPath(ma multiaddr.Multiaddr) (target string) { - multiaddr.ForEach(ma, func(comp multiaddr.Component) bool { - isUnixComponent := comp.Protocol().Code == multiaddr.P_UNIX - if isUnixComponent { - target = comp.Value() - if runtime.GOOS == "windows" { // `/C:\path` -> `C:\path` - target = strings.TrimPrefix(target, `/`) - } - return true - } - return false - }) - return -} - -func (ld *Listener) UnlinkAt(name string, flags uint32) error { - _, lFile, err := ld.Walk([]string{name}) - if err != nil { - return err - } - - // TODO: we should do an internal [Open] with flags [ORCLOSE] - // then unlink from our table, - // then return the result of file.Close (which itself calls listener.Close) - - ulErr := ld.directory.UnlinkAt(name, flags) - if lf, ok := lFile.(*listenerFile); ok { - if listener := lf.Listener; listener != nil { - if err := listener.Close(); err != nil { - return err - } - } - } - return ulErr -} - -func (lf *listenerFile) SetAttr(valid p9.SetAttrMask, attr p9.SetAttr) error { - return lf.metadata.SetAttr(valid, attr) -} - -func (lf *listenerFile) GetAttr(req p9.AttrMask) (p9.QID, p9.AttrMask, p9.Attr, error) { - return lf.metadata.GetAttr(req) -} - -func (lf *listenerFile) clone(withQID bool) ([]p9.QID, *listenerFile, error) { - var ( - qids []p9.QID - newLf = &listenerFile{ - metadata: lf.metadata, - Listener: lf.Listener, - // maddrReader: lf.maddrReader, - } - ) - if withQID { - qids = []p9.QID{*newLf.QID} - } - return qids, newLf, nil -} - -func (lf *listenerFile) Walk(names []string) ([]p9.QID, p9.File, error) { - return walk[*listenerFile](lf, names...) -} - -func (lf *listenerFile) Open(mode p9.OpenFlags) (p9.QID, ioUnit, error) { - if lf.fidOpened() { - return p9.QID{}, 0, perrors.EBADF - } - lf.openFlag = true - if mode.Mode() != p9.ReadOnly { - // TODO: [spec] correct evalue? - return p9.QID{}, 0, perrors.EINVAL - } - // TODO: this but properly, cache the reader and whatnot. - // lf.maddrReader = bytes.NewReader(lf.Listener.Multiaddr().Bytes()) - return *lf.QID, 0, nil -} - -func (lf *listenerFile) ReadAt(p []byte, offset int64) (int, error) { - if !lf.fidOpened() { // TODO: spec compliance check - may need to check flags too. - return 0, perrors.EINVAL - } - // TODO: properly cache the reader here. - // return lf.maddrReader.ReadAt(p, offset) - return bytes.NewReader(lf.Listener.Multiaddr().Bytes()).ReadAt(p, offset) -} - -func (udl *listenerUDSWrapper) Close() error { - var ( - lErr = udl.Listener.Close() - cErr = udl.closeFunc() - ) - if cErr == nil { - return lErr - } - return cErr -} diff --git a/internal/files/mount.go b/internal/files/mount.go deleted file mode 100644 index 81d5ac69..00000000 --- a/internal/files/mount.go +++ /dev/null @@ -1,122 +0,0 @@ -package files - -import ( - "errors" - - "github.com/djdv/go-filesystem-utils/internal/filesystem" - "github.com/hugelgupf/p9/p9" -) - -// TODO: docs; recommended / default value for this file's name -const MounterName = "mounts" - -type Mounter struct { - directory - cleanupEmpties bool -} - -// TODO: temporary; -// NOTE: Assure that we return a real concrete type. -// otherwise t-e2e is required (l&r). -func NewMounter(options ...MounterOption) *Mounter { - var settings mounterSettings - if err := parseOptions(&settings, options...); err != nil { - panic(err) - } - var ( - fsys directory - unlinkSelf = settings.cleanupSelf - directoryOptions = []DirectoryOption{ - WithSuboptions[DirectoryOption](settings.metaSettings.asOptions()...), - WithSuboptions[DirectoryOption](settings.linkSettings.asOptions()...), - } - ) - if unlinkSelf { - _, fsys = newEphemeralDirectory(directoryOptions...) - } else { - _, fsys = NewDirectory(directoryOptions...) - } - return &Mounter{ - directory: fsys, - cleanupEmpties: settings.cleanupElements, - } -} - -func (mn *Mounter) clone(withQID bool) ([]p9.QID, *Mounter, error) { - var wnames []string - if withQID { - wnames = []string{selfWName} - } - qids, dirClone, err := mn.directory.Walk(wnames) - if err != nil { - return nil, nil, err - } - typedDir, err := assertDirectory(dirClone) - if err != nil { - return nil, nil, err - } - newDir := &Mounter{ - directory: typedDir, - - cleanupEmpties: mn.cleanupEmpties, - } - if err != nil { - return nil, nil, err - } - return qids, newDir, nil -} - -/* -func (mn *Mounter) Walk(names []string) ([]p9.QID, p9.File, error) { - return walk[*Mounter](mn, names...) -} - -func (mn *Mounter) Open(mode p9.OpenFlags) (p9.QID, ioUnit, error) { -} -*/ - -func (mn *Mounter) Mkdir(name string, permissions p9.FileMode, _ p9.UID, gid p9.GID) (p9.QID, error) { - hostAPI, err := filesystem.ParseAPI(name) - if err != nil { - return p9.QID{}, err - } - attr, err := mkdirInherit(mn, permissions, gid) - if err != nil { - return p9.QID{}, err - } - var ( - metaOptions = []MetaOption{ - WithPath(mn.directory.path()), - WithBaseAttr(attr), - WithAttrTimestamps(true), - } - linkOptions = []LinkOption{ - WithParent(mn, name), - } - generatorOptions []GeneratorOption - ) - if mn.cleanupEmpties { - generatorOptions = append(generatorOptions, - CleanupSelf(true), - CleanupEmpties(true), - ) - } - switch hostAPI { - /* - case filesystem.Plan9Protocol: - // FIXME: implement - return p9.QID{}, errors.New("not fully implemented yet") - qid, nineDir := NewNineDir(WithSuboptions[NineOption](directoryOptions...)) - return qid, dir.Link(nineDir, name) - */ - case filesystem.Fuse: - qid, fuseDir := NewFuseDir( - WithSuboptions[FuseOption](metaOptions...), - WithSuboptions[FuseOption](linkOptions...), - WithSuboptions[FuseOption](generatorOptions...), - ) - return qid, mn.Link(fuseDir, name) - default: - return p9.QID{}, errors.New("unexpected host") // TODO: msg - } -} diff --git a/internal/files/multiaddr.go b/internal/files/multiaddr.go deleted file mode 100644 index 0294fd8b..00000000 --- a/internal/files/multiaddr.go +++ /dev/null @@ -1,36 +0,0 @@ -package files - -import ( - "bytes" - "encoding/json" - "fmt" - - "github.com/multiformats/go-multiaddr" -) - -type ipfsAPIMultiaddr struct{ multiaddr.Multiaddr } - -func (maddr *ipfsAPIMultiaddr) UnmarshalBinary(b []byte) (err error) { - maddr.Multiaddr, err = multiaddr.NewMultiaddrBytes(b) - return -} - -func (maddr *ipfsAPIMultiaddr) UnmarshalText(b []byte) (err error) { - maddr.Multiaddr, err = multiaddr.NewMultiaddr(string(b)) - return -} - -func (maddr *ipfsAPIMultiaddr) UnmarshalJSON(b []byte) (err error) { - if len(b) < 2 || bytes.Equal(b, []byte("{}")) { - return fmt.Errorf("response was empty or short: `%v`", b) - } - if bytes.Equal(b, []byte("null")) { - return - } - var maddrStr string - if err = json.Unmarshal(b, &maddrStr); err != nil { - return - } - maddr.Multiaddr, err = multiaddr.NewMultiaddr(maddrStr) - return -} diff --git a/internal/files/operations.go b/internal/files/operations.go deleted file mode 100644 index c783e8cf..00000000 --- a/internal/files/operations.go +++ /dev/null @@ -1,180 +0,0 @@ -package files - -import ( - "errors" - "io" - "math" - - "github.com/hugelgupf/p9/p9" - "github.com/hugelgupf/p9/perrors" -) - -/* TODO: maybe scrap this. -type WalkDirFunc func(path string, d p9.Dirent, err error) error - -// TODO: dedupe mkdirall, removeEmpties, flattenX, et al. with this. -func WalkDir(fsys p9.File, fn WalkDirFunc) error { - var ( - closers = make([]io.Closer, 0, len(names)) - closeAll = func() error { - for _, c := range closers { - if err := c.Close(); err != nil { - return err - } - } - closers = nil - return nil - } - ) - defer closeAll() // TODO: error needs to be caught and appended if we return early. - // TODO: this could be real-time (callbacks or channels vs slices). - files, err := flattenDir(fsys) - if err != nil { - return err - } - for _, f := range files { - // TODO: signature isn't currently very useful for us - } - return closeAll() -} - -func flattenDir(dir p9.File) ([]p9.File, error) { - ents, err := ReadDir(dir) - if err != nil { - return nil, err - } - var ( - files = make([]p9.File, 0, len(ents)) - // TODO: micro-opt; is this faster than allocating in the loop? - wnames = make([]string, 1) - ) - for _, ent := range ents { - wnames[0] = ent.Name - _, entFile, err := dir.Walk(wnames) - if err != nil { - return nil, err - } - if ent.Type == p9.TypeDir { - submaddrs, err := flattenDir(entFile) - if err != nil { - return nil, err - } - files = append(files, submaddrs...) - continue - } - files = append(files, entFile) - } - return files, nil -} -*/ - -func MkdirAll(root p9.File, names []string, - permissions p9.FileMode, uid p9.UID, gid p9.GID, -) (p9.File, error) { - var ( - closers = make([]io.Closer, 0, len(names)) - closeAll = func() error { - for _, c := range closers { - if err := c.Close(); err != nil { - return err - } - } - closers = nil - return nil - } - ) - defer closeAll() // TODO: error needs to be caught and appended if we return early. - var ( - tail = len(names) - 1 - wnames = make([]string, 1) - next = root - ) - for i, name := range names { - wnames[0] = name - _, nextF, err := next.Walk(wnames) - if err != nil { - if !errors.Is(err, perrors.ENOENT) { - return nil, err - } - if _, err := next.Mkdir(name, permissions, uid, gid); err != nil { - return nil, err - } - if _, nextF, err = next.Walk(wnames); err != nil { - return nil, err - } - } - if i != tail { - closers = append(closers, nextF) - } - next = nextF - } - if err := closeAll(); err != nil { - return nil, err - } - return next, nil -} - -// TODO: export this? But where? What name? ReaddirAll? -// *We're using the same name as [os] (new canon) -// and [fs] (newer canon) for now, make sure this causes no issues. -func ReadDir(dir p9.File) (_ p9.Dirents, err error) { - _, dirClone, err := dir.Walk(nil) - if err != nil { - return nil, err - } - if _, _, err := dirClone.Open(p9.ReadOnly); err != nil { - return nil, err - } - defer func() { - cErr := dirClone.Close() - if err == nil { - err = cErr - } - }() - var ( - offset uint64 - ents p9.Dirents - ) - for { // TODO: [Ame] double check correctness (offsets and that) - entBuf, err := dirClone.Readdir(offset, math.MaxUint32) - if err != nil { - return nil, err - } - bufferedEnts := len(entBuf) - if bufferedEnts == 0 { - break - } - offset = entBuf[bufferedEnts-1].Offset - ents = append(ents, entBuf...) - } - return ents, nil -} - -func ReadAll(file p9.File) (_ []byte, err error) { - // TODO: walkgetattr with fallback. - _, fileClone, err := file.Walk(nil) - if err != nil { - return nil, err - } - - want := p9.AttrMask{Size: true} - _, valid, attr, err := fileClone.GetAttr(want) - if err != nil { - return nil, err - } - if !valid.Contains(want) { - return nil, attrErr(valid, want) - } - - if _, _, err := fileClone.Open(p9.ReadOnly); err != nil { - return nil, err - } - defer func() { - cErr := fileClone.Close() - if err == nil { - err = cErr - } - }() - sr := io.NewSectionReader(fileClone, 0, int64(attr.Size)) - return io.ReadAll(sr) -} diff --git a/internal/files/options.go b/internal/files/options.go deleted file mode 100644 index 29f8df33..00000000 --- a/internal/files/options.go +++ /dev/null @@ -1,308 +0,0 @@ -package files - -import ( - "context" - "fmt" - "reflect" // [fa0f68c3-8fdc-445a-9ddf-699da39a77c2] - "sync/atomic" - "unsafe" // [fa0f68c3-8fdc-445a-9ddf-699da39a77c2] - - "github.com/djdv/go-filesystem-utils/internal/filesystem" - "github.com/hugelgupf/p9/p9" -) - -// TODO: some way to provide statfs for files that are themselves, -// not devices, but hosted inside one. -// -// Implementations should probably have a default of `0x01021997` (V9FS_MAGIC) for `f_type` -// Or we can make up our own magic numbers (something not already in use) -// to guarantee we're not misinterpreted (as a FS that we're not) -// by callers / the OS (Linux specifically). -// -// The Linux manual has this to say about `f_fsid` -// "Nobody knows what f_fsid is supposed to contain" ... -// we'll uhhh... figure something out later I guess. - -type ( - metaSettings struct { - metadata - withTimestamps bool - } - MetaOption func(*metaSettings) error - - linkSettings struct { - parent p9.File - name string - } - LinkOption func(*linkSettings) error - - fileSettings struct { - metaSettings - linkSettings - } - FileOption func(*fileSettings) error - - directorySettings struct { - fileSettings - } - DirectoryOption func(*directorySettings) error - - generatorSettings struct { - cleanupSelf bool // TODO: better name? Different container? - cleanupElements bool // TODO: better name? cleanupItems? - } - GeneratorOption func(*generatorSettings) error - - listenerSettings struct { - directorySettings - generatorSettings - } - ListenerOption func(*listenerSettings) error - - mounterSettings struct { - directorySettings - generatorSettings - } - MounterOption func(*mounterSettings) error - - fuseSettings struct { - directorySettings - generatorSettings - } - FuseOption func(*fuseSettings) error - - fsidSettings struct { - directorySettings - generatorSettings - hostAPI filesystem.API - } - FSIDOption func(*fsidSettings) error - - ipfsSettings struct { - fileSettings - // TODO: node addr, etc. - } - IPFSOption func(*ipfsSettings) error - - NineOption (func()) // TODO stub -) - -func parseOptions[ST any, OT ~func(*ST) error](settings *ST, options ...OT) error { - for _, setFunc := range options { - if err := setFunc(settings); err != nil { - return err - } - } - return nil -} - -// TODO: This needs an example code to make sense. -// -// WithSuboptions converts shared option types -// into the requested option type. -func WithSuboptions[ - NOT ~func(*NST) error, // New Option Type. - NST, OST any, // X Settings Type. - OOT ~func(*OST) error, // Old Option Type. -](options ...OOT, -) NOT { - return func(s *NST) error { - ptr, err := hackGetPtr[OST](s) - if err != nil { - return err - } - return parseOptions(ptr, options...) - } -} - -func WithPath(path *atomic.Uint64) MetaOption { - return func(set *metaSettings) error { set.ninePath = path; return nil } -} - -// TODO: docs -// Constructors may use this attr freely. -// (Fields may be ignored or modified.) -func WithBaseAttr(attr *p9.Attr) MetaOption { - return func(set *metaSettings) error { - /* TODO: lint; disallow this? Merge attrs? <- yeah probably. - if existing := set.Attr; existing != nil { - return fmt.Errorf("base attr already set:%v", existing) - } - */ - set.Attr = attr - return nil - } -} - -func WithAttrTimestamps(b bool) MetaOption { - return func(ms *metaSettings) error { ms.withTimestamps = true; return nil } -} - -// TODO: name is the name of the child, in relation to the parent, not the parent node's name. -// We need a good variable-name for this. selfName? ourName? -func WithParent(parent p9.File, name string) LinkOption { - return func(ls *linkSettings) error { ls.parent = parent; ls.name = name; return nil } -} - -// TODO: name? + docs -func CleanupSelf(b bool) GeneratorOption { - return func(set *generatorSettings) error { set.cleanupSelf = b; return nil } -} - -// TODO: name? + docs -func CleanupEmpties(b bool) GeneratorOption { - return func(set *generatorSettings) error { set.cleanupElements = b; return nil } -} - -// TODO: we should either export these settings reflectors or make a comparable function. -// Even better would be to eliminate the need for them all together. - -func (settings *metaSettings) asOptions() []MetaOption { - return []MetaOption{ - WithPath(settings.ninePath), - WithBaseAttr(settings.Attr), - WithAttrTimestamps(settings.withTimestamps), - } -} - -func (settings *linkSettings) asOptions() []LinkOption { - return []LinkOption{ - WithParent(settings.parent, settings.name), - } -} - -// TODO: [Ame] Words words words. How about some concision? -// HACK: [Go 1.19] [fa0f68c3-8fdc-445a-9ddf-699da39a77c2] -// Several proposals have been accepted within Go [*1] -// which allow several possible implementations of what we're trying to do here. -// None of which have been implemented in the compiler yet. -// Until then, this is the best I could come up with. -// [*1] common struct fields; type parameters on methods; mixed concrete+interface unions; and more. -// -// The implementation is bad, but should be amendable later -// without changing the calling code. -// -// This should be done with generics in a type safe, compile-time way -// when that's possible. -func hackGetPtr[T, V any](source *V) (*T, error) { - var ( - sourceValue = reflect.ValueOf(source).Elem() - targetType = reflect.TypeOf((*T)(nil)).Elem() - ctx, cancel = context.WithCancel(context.Background()) - ) - defer cancel() - for field := range fieldsFromStruct(ctx, sourceValue.Type()) { - if field.Type == targetType { - fieldVal := sourceValue.FieldByIndex(field.Index) - if !field.IsExported() { - return hackEscapeRuntime[T](fieldVal) - } - return hackAssert[T](fieldVal.Interface()) - } - } - // TODO: can we prevent this at compile time today (v1.19)? - // use an "implements" interface? Probably not. - return nil, fmt.Errorf("could not find type \"%s\" within \"%T\"", - targetType.Name(), source, - ) -} - -// XXX: [Go 1.19] [fa0f68c3-8fdc-445a-9ddf-699da39a77c2] -// Returns an instance of the field's address, -// without the runtime's read-only flag. -func hackEscapeRuntime[T any](field reflect.Value) (*T, error) { - var ( - fieldAddr = unsafe.Pointer(field.UnsafeAddr()) - fieldWrite = reflect.NewAt(field.Type(), fieldAddr) - ) - return hackAssert[T](fieldWrite.Interface()) -} - -// TODO: [Go 1.19] [fa0f68c3-8fdc-445a-9ddf-699da39a77c2] -func hackAssert[T any](value any) (*T, error) { - concrete, ok := value.(*T) - if !ok { - err := fmt.Errorf("type mismatch"+ - "\n\tgot: %T"+ - "\n\twant: %T", - concrete, (*T)(nil), - ) - return nil, err - } - return concrete, nil -} - -// TODO: [micro-opt] [benchmarks] -// Considering we're searching for structs that are very likely to be top level embeds, -// we want breadth first search on structs. -// (As opposed to [reflect.VisibleFields]'s lexical-depth order, or whatever.) -// However, it's likely more effect to use slices, not channels+goroutines here. -// This code was already written for something else, and re-used/adapted here. -// (Author: djdv, Takers: anyone) - -type structFields = <-chan reflect.StructField - -// fieldsFromStruct returns the fields from [typ] in breadth first order. -func fieldsFromStruct(ctx context.Context, typ reflect.Type) structFields { - out := make(chan reflect.StructField) - go func() { - defer close(out) - queue := []structFields{generateFields(ctx, typ)} - for len(queue) != 0 { - var cur structFields - cur, queue = queue[0], queue[1:] - for field := range cur { - select { - case out <- field: - if kind := field.Type.Kind(); kind == reflect.Struct { - queue = append(queue, expandField(ctx, field)) - } - case <-ctx.Done(): - return - } - } - } - }() - return out -} - -func generateFields(ctx context.Context, typ reflect.Type) structFields { - var ( - fieldCount = typ.NumField() - fields = make(chan reflect.StructField, fieldCount) - ) - go func() { - defer close(fields) - for i := 0; i < fieldCount; i++ { - if ctx.Err() != nil { - return - } - fields <- typ.Field(i) - } - }() - return fields -} - -// expandField generates fields from a field, -// and prefixes their index with their container's index. -// (I.e. received [field.Index] may be passed to [container.FieldByIndex]) -func expandField(ctx context.Context, field reflect.StructField) structFields { - embeddedFields := generateFields(ctx, field.Type) - return prefixIndex(ctx, field.Index, embeddedFields) -} - -func prefixIndex(ctx context.Context, prefix []int, fields structFields) structFields { - prefixed := make(chan reflect.StructField, cap(fields)) - go func() { - defer close(prefixed) - for field := range fields { - field.Index = append(prefix, field.Index...) - select { - case prefixed <- field: - case <-ctx.Done(): - return - } - } - }() - return prefixed -} diff --git a/internal/files/stat.go b/internal/files/stat.go deleted file mode 100644 index 2d3b6ed5..00000000 --- a/internal/files/stat.go +++ /dev/null @@ -1,424 +0,0 @@ -package files - -import ( - "fmt" - "sync/atomic" - "time" - - "github.com/hugelgupf/p9/p9" -) - -type ioUnit = uint32 - -const ( - noIOUnit ioUnit = 0 - - // Permission mode bits. - // - // POSIX. - - S_IROTH p9.FileMode = p9.Read - S_IWOTH = p9.Write - S_IXOTH = p9.Exec - - i_permissionModeShift = 3 - - S_IRGRP = S_IROTH << i_permissionModeShift - S_IWGRP = S_IWOTH << i_permissionModeShift - S_IXGRP = S_IXOTH << i_permissionModeShift - - S_IRUSR = S_IRGRP << i_permissionModeShift - S_IWUSR = S_IWGRP << i_permissionModeShift - S_IXUSR = S_IXGRP << i_permissionModeShift - - S_IRWXO = S_IROTH | S_IWOTH | S_IXOTH - S_IRWXG = S_IRGRP | S_IWGRP | S_IXGRP - S_IRWXU = S_IRUSR | S_IWUSR | S_IXUSR - - // Non-standard. - - S_IXA = S_IXUSR | S_IXGRP | S_IXOTH // POSIX: 0o111 - S_IWA = S_IWUSR | S_IWGRP | S_IWOTH // POSIX: 0o222 - S_IRA = S_IRUSR | S_IRGRP | S_IROTH // POSIX: 0o444 - S_IRXA = S_IRA | S_IXA // POSIX: 0o555 - S_IRWA = S_IRA | S_IWA // POSIX: 0o666 - S_IRWXA = S_IRWXU | S_IRWXG | S_IRWXO // POSIX: 0o777 - - // TODO: operation masks should be configurable during node creation? - // Currently operations are hardcoded to use Linux umask(2) style. - - // Plan 9 - Open(5) Create masks. - - // S_REGMSK = S_IRWXA &^ (S_IXUSR | S_IXGRP | S_IXOTH) - // S_DIRMSK = S_IRWXA - - // TODO: where used, should be variable. With this only being the default. - // umask must be configurable at runtime, at least at the root level. - - // Linux - Open(2) umask. - - S_LINMSK = S_IWGRP | S_IWOTH - - s_SCKMSK = S_IXA -) - -type ( - ninePath = *atomic.Uint64 - metadata struct { - ninePath - *p9.Attr - *p9.QID - } - - openFlag bool - - unixTime struct { - seconds, nanoseconds *uint64 - } -) - -func (b openFlag) fidOpened() bool { return bool(b) } - -func (md metadata) SetAttr(valid p9.SetAttrMask, attr p9.SetAttr) error { - var ( - ourAtime = !valid.ATimeNotSystemTime - ourMtime = !valid.MTimeNotSystemTime - cTime = valid.CTime - usingClock = ourAtime || ourMtime || cTime - ) - if usingClock { - for _, time := range []struct { - name string - useClock, setField bool - }{ - {useClock: ourAtime, setField: valid.ATime, name: "A"}, - {useClock: ourMtime, setField: valid.MTime, name: "M"}, - } { - if time.useClock && !time.setField { - const ( - namePrefix = "P9_SETATTR_" - nameSuffix = "TIME" - ) - return fmt.Errorf( - "system time requested, but corresponding time field (%s) was not set", - namePrefix+time.name+nameSuffix, - ) - } - } - timestamp(timePointers(md.Attr, ourAtime, ourMtime, cTime)) - for _, b := range []struct { - timeFlag *bool - clockFlag bool - }{ - { - timeFlag: &valid.ATime, - clockFlag: ourAtime, - }, - { - timeFlag: &valid.MTime, - clockFlag: ourMtime, - }, - } { - if b.clockFlag { - *b.timeFlag = false - } - } - } - md.Apply(valid, attr) - return nil -} - -func (md metadata) GetAttr(req p9.AttrMask) (p9.QID, p9.AttrMask, p9.Attr, error) { - var ( - qid = *md.QID - filled, attr = fillAttrs(req, md.Attr) - ) - return qid, filled, *attr, nil -} - -func (md *metadata) path() ninePath { return md.ninePath } - -func initMetadata(metadata *metadata, fileType p9.FileMode, withTimestamps bool) { - attr := metadata.Attr - if attr == nil { - attr = &p9.Attr{ - UID: p9.NoUID, - GID: p9.NoGID, - } - metadata.Attr = attr - } - attr.Mode |= fileType - if withTimestamps { - timestamp(timePointers(attr, true, true, true)) - } - path := metadata.ninePath - if path == nil { - path = new(atomic.Uint64) - metadata.ninePath = path - } - var ( - pathNum = path.Add(1) - qidType = fileType.QIDType() - ) - metadata.QID = &p9.QID{ - Type: qidType, - Path: pathNum, - } -} - -func timePointers(attr *p9.Attr, A, M, C bool) []unixTime { - var fields int - for _, b := range []bool{A, M, C} { - if b { - fields++ - } - } - times := make([]unixTime, 0, fields) - for _, t := range []struct { - seconds, nanoseconds *uint64 - setFlag bool - }{ - { - setFlag: A, - seconds: &attr.ATimeSeconds, - nanoseconds: &attr.ATimeNanoSeconds, - }, - { - setFlag: M, - seconds: &attr.MTimeSeconds, - nanoseconds: &attr.MTimeNanoSeconds, - }, - { - setFlag: C, - seconds: &attr.CTimeSeconds, - nanoseconds: &attr.CTimeNanoSeconds, - }, - } { - if t.setFlag { - times = append(times, unixTime{ - seconds: t.seconds, - nanoseconds: t.nanoseconds, - }) - } - } - return times -} - -func timestamp(times []unixTime) { - var ( - now = time.Now() - nowSec = uint64(now.Unix()) - nowNano = uint64(now.UnixNano()) - ) - for _, field := range times { - *field.seconds, *field.nanoseconds = nowSec, nowNano - } -} - -func fillAttrs(req p9.AttrMask, attr *p9.Attr) (p9.AttrMask, *p9.Attr) { - var ( - rAttr p9.Attr - valid p9.AttrMask - ) - if req.Empty() { - return valid, &rAttr - } - if req.Mode { - mode := attr.Mode - rAttr.Mode, valid.Mode = mode, mode != 0 - } - if req.UID { - uid := attr.UID - rAttr.UID, valid.UID = uid, uid.Ok() - } - if req.GID { - gid := attr.GID - rAttr.GID, valid.GID = gid, gid.Ok() - } - if req.RDev { - rDev := attr.RDev - rAttr.RDev, valid.RDev = rDev, rDev != 0 - } - if req.ATime { - var ( - sec = attr.ATimeSeconds - nano = attr.ATimeNanoSeconds - ) - rAttr.ATimeSeconds, rAttr.ATimeNanoSeconds, valid.ATime = sec, nano, nano != 0 - } - if req.MTime { - var ( - sec = attr.MTimeSeconds - nano = attr.MTimeNanoSeconds - ) - rAttr.MTimeSeconds, rAttr.MTimeNanoSeconds, valid.MTime = sec, nano, nano != 0 - } - if req.CTime { - var ( - sec = attr.CTimeSeconds - nano = attr.CTimeNanoSeconds - ) - rAttr.CTimeSeconds, rAttr.CTimeNanoSeconds, valid.CTime = sec, nano, nano != 0 - } - if req.Size { - rAttr.Size, valid.Size = attr.Size, !attr.Mode.IsDir() - } - return valid, &rAttr -} - -func setAttr(file p9.File, attr *p9.Attr, withServerTimes bool) error { - valid, setAttr := attrToSetAttr(attr) - if withServerTimes { - valid.ATime = true - valid.MTime = true - valid.CTime = true - } - return file.SetAttr(valid, setAttr) -} - -func getAttrs(file p9.File, want p9.AttrMask) (*p9.Attr, error) { - _, valid, attr, err := file.GetAttr(want) - if err != nil { - return nil, err - } - if !valid.Contains(want) { - return nil, attrErr(valid, want) - } - return &attr, nil -} - -func maybeGetAttrs(file p9.File, want, required p9.AttrMask) (*p9.Attr, error) { - _, valid, attr, err := file.GetAttr(want) - if err != nil { - return nil, err - } - if !valid.Contains(required) { - return nil, attrErr(valid, want) - } - if want.UID && !valid.UID { - attr.UID = p9.NoUID - } - if want.GID && !valid.GID { - attr.GID = p9.NoGID - } - return &attr, nil -} - -func attrErr(got, want p9.AttrMask) error { - return fmt.Errorf("did not receive expected attributes"+ - "\n\tgot: %s"+ - "\n\twant: %s", - got, want, - ) -} - -/* [lint] Seems worse than the if-wall. Maybe can be reworked? -for _ field := range []struct { - requested, isValid bool - rValid *bool - value , rValue any -} { - { - requested: req.Size, - isValid: !attr.IsDir(), - value: attr.Size, - rValid: &valid.Size, - rValue: &rAttr.Size, - } -}{ - fillAttr(field.requested, - field.isValid, field.value, - field.rValue, field.rValue, - ) -} - -fillAttr(req.Size, - true, attr.Size, - &valid.Size, &rAttr.Size, -) -func fillAttr[T any](requested, isValid bool, value T, rValid *bool, rValue *T, -) { - if requested && isValid() { - *rValue, *rValid = value, true -} -} -*/ - -func attrToSetAttr(source *p9.Attr) (p9.SetAttrMask, p9.SetAttr) { - var ( - valid p9.SetAttrMask - attr p9.SetAttr - uid = source.UID - gid = source.GID - ) - if permissions := source.Mode.Permissions(); permissions != 0 { - attr.Permissions, valid.Permissions = permissions, true - } - attr.UID, valid.UID = uid, uid.Ok() - attr.GID, valid.GID = gid, gid.Ok() - if size := source.Size; size != 0 { - attr.Size, valid.Size = size, true - } - for _, timeAttr := range []struct { - setTime, localTime *bool - value uint64 - }{ - { - value: source.ATimeNanoSeconds, - setTime: &valid.ATime, - localTime: &valid.ATimeNotSystemTime, - }, - { - value: source.MTimeNanoSeconds, - setTime: &valid.MTime, - localTime: &valid.MTimeNotSystemTime, - }, - } { - if timeAttr.value != 0 { - *timeAttr.setTime = true - *timeAttr.localTime = true - } - } - return valid, attr -} - -func mkdirMask(permissions p9.FileMode) p9.FileMode { return (permissions &^ S_LINMSK) & S_IRWXA } -func mknodMask(permissions p9.FileMode) p9.FileMode { return permissions &^ S_LINMSK } -func socketMask(permissions p9.FileMode) p9.FileMode { return permissions &^ (S_LINMSK | s_SCKMSK) } - -func maybeInheritUID(parent p9.File) (*p9.Attr, error) { - var ( - want = p9.AttrMask{UID: true} - required = p9.AttrMask{} - ) - return maybeGetAttrs(parent, want, required) -} - -// TODO: better name. mkdirFillAttr? -// TODO: 9P2000.L does not define UID as part of mkdir messages. -// The library/fork we're using should probably remove it from the method interface. -func mkdirInherit(parent p9.File, permissions p9.FileMode, gid p9.GID) (*p9.Attr, error) { - attr, err := maybeInheritUID(parent) - if err != nil { - return nil, err - } - return &p9.Attr{ - Mode: mkdirMask(permissions), - UID: attr.UID, - GID: gid, - }, nil -} - -// TODO: 9P2000.L does not define UID as part of mknod messages. -// The library/fork we're using should probably remove it from the method interface. -func mknodInherit(parent p9.File, permissions p9.FileMode, gid p9.GID) (*p9.Attr, error) { - attr, err := maybeInheritUID(parent) - if err != nil { - return nil, err - } - return &p9.Attr{ - Mode: mknodMask(permissions), - UID: attr.UID, - GID: gid, - }, nil -} diff --git a/internal/files/walk.go b/internal/files/walk.go deleted file mode 100644 index 8408359e..00000000 --- a/internal/files/walk.go +++ /dev/null @@ -1,82 +0,0 @@ -package files - -import ( - "log" - - "github.com/hugelgupf/p9/p9" - "github.com/hugelgupf/p9/perrors" -) - -const ( - selfWName = "." - parentWName = ".." -) - -type ( - // TODO: namen - placeholderName interface { - fidOpened() bool - } - childWalker interface { - parent() p9.File - } - fileWalker[F p9.File] interface { - placeholderName - clone(withQid bool) ([]p9.QID, F, error) - } - dirWalker[F p9.File] interface { - fileWalker[F] - load(name string) (p9.File, bool) - } -) - -func walk[F p9.File](file fileWalker[F], names ...string) ([]p9.QID, p9.File, error) { - if file.fidOpened() { - log.Printf("already opened: %p", file) - return nil, nil, perrors.EINVAL // TODO: [spec] correct evalue? - } - const ( - withoutQID = false - withQID = true - ) - switch nameCount := len(names); nameCount { - case 0: - return file.clone(withoutQID) - case 1: - switch names[0] { - case parentWName: - if child, ok := file.(childWalker); ok { - return child.parent().Walk([]string{selfWName}) - } - fallthrough - case selfWName: - return file.clone(withQID) - } - } - if dir, ok := file.(dirWalker[F]); ok { - return walkRecur[dirWalker[F]](dir, names...) - } - return nil, nil, perrors.ENOTDIR -} - -func walkRecur[D dirWalker[F], F p9.File](dir dirWalker[F], names ...string) ([]p9.QID, p9.File, error) { - file, ok := dir.load(names[0]) - if !ok { - return nil, nil, perrors.ENOENT - } - qids := make([]p9.QID, 1, len(names)) - qid, _, _, err := file.GetAttr(p9.AttrMask{}) - if err != nil { - return nil, nil, err - } - if qids[0] = qid; len(qids) == cap(qids) { - return qids, file, nil - } - - subNames := names[1:] - subQids, subFile, err := file.Walk(subNames) - if err != nil { - return nil, nil, err - } - return append(qids, subQids...), subFile, nil -} diff --git a/internal/filesystem/9p/chan.go b/internal/filesystem/9p/chan.go new file mode 100644 index 00000000..d1105a17 --- /dev/null +++ b/internal/filesystem/9p/chan.go @@ -0,0 +1,131 @@ +package p9 + +import ( + "context" + "fmt" + + "github.com/djdv/go-filesystem-utils/internal/generic" + perrors "github.com/djdv/p9/errors" + "github.com/djdv/p9/fsimpl/templatefs" + "github.com/djdv/p9/p9" +) + +type ( + ChannelFile struct { + templatefs.NoopFile + *metadata + *linkSync + emitter *chanEmitter[[]byte] + openFlags + } + channelSettings struct { + buffer int + } + channelFileSettings struct { + fileSettings + channelSettings + } + ChannelOption func(*channelFileSettings) error + channelSetter[T any] interface { + *T + setBuffer(int) + } +) + +func (cs *channelSettings) setBuffer(size int) { cs.buffer = size } + +func NewChannelFile(ctx context.Context, + options ...ChannelOption, +) (p9.QID, *ChannelFile, <-chan []byte, error) { + var settings channelFileSettings + settings.metadata.initialize(p9.ModeRegular) + if err := generic.ApplyOptions(&settings, options...); err != nil { + return p9.QID{}, nil, nil, err + } + var ( + emitter = makeChannelEmitter[[]byte]( + ctx, + settings.buffer, + ) + bytesChan = emitter.ch + file = &ChannelFile{ + metadata: &settings.metadata, + linkSync: &settings.linkSync, + emitter: emitter, + } + ) + settings.metadata.fillDefaults() + settings.metadata.incrementPath() + return settings.QID, file, bytesChan, nil +} + +func WithBuffer[ + OT generic.OptionFunc[T], + T any, + I channelSetter[T], +](size int, +) OT { + return func(channelFile *T) error { + any(channelFile).(I).setBuffer(size) + return nil + } +} + +func (cf *ChannelFile) Walk(names []string) ([]p9.QID, p9.File, error) { + if len(names) > 0 { + return nil, nil, perrors.ENOTDIR + } + if cf.opened() { + return nil, nil, fidOpenedErr + } + return nil, &ChannelFile{ + metadata: cf.metadata, + linkSync: cf.linkSync, + emitter: cf.emitter, + }, nil +} + +func (cf *ChannelFile) Open(mode p9.OpenFlags) (p9.QID, ioUnit, error) { + if cf.opened() { + return p9.QID{}, 0, perrors.EBADF + } + if mode.Mode() != p9.WriteOnly { + // TODO: [spec] correct evalue? + return p9.QID{}, 0, perrors.EINVAL + } + cf.openFlags = cf.withOpenedFlag(mode) + return cf.QID, 0, nil +} + +func (cf *ChannelFile) Close() error { + cf.openFlags = 0 + return nil +} + +func (cf *ChannelFile) WriteAt(p []byte, _ int64) (int, error) { + if !cf.canWrite() { + return -1, perrors.EBADF + } + if err := cf.emitter.emit(p); err != nil { + // TODO: spec error value + // TODO: Go 1.20 will allow multiple %w + return -1, fmt.Errorf("%w - %s", perrors.EIO, err) + } + return len(p), nil +} + +func (cf *ChannelFile) SetAttr(valid p9.SetAttrMask, attr p9.SetAttr) error { + return cf.metadata.SetAttr(valid, attr) +} + +func (cf *ChannelFile) GetAttr(req p9.AttrMask) (p9.QID, p9.AttrMask, p9.Attr, error) { + return cf.metadata.GetAttr(req) +} + +func (cf *ChannelFile) Rename(newDir p9.File, newName string) error { + return cf.linkSync.rename(cf, newDir, newName) +} + +func (cf *ChannelFile) Renamed(newDir p9.File, newName string) { + cf.linkSync.Renamed(newDir, newName) +} diff --git a/internal/filesystem/9p/directory.go b/internal/filesystem/9p/directory.go new file mode 100644 index 00000000..6c767595 --- /dev/null +++ b/internal/filesystem/9p/directory.go @@ -0,0 +1,362 @@ +package p9 + +import ( + "errors" + "fmt" + "sync/atomic" + + "github.com/djdv/go-filesystem-utils/internal/generic" + perrors "github.com/djdv/p9/errors" + "github.com/djdv/p9/fsimpl/templatefs" + "github.com/djdv/p9/p9" +) + +const parentWName = ".." + +type ( + // Embeddable alias with a more apt name. + directory = p9.File + Directory struct { + templatefs.NoopFile + *fileTableSync + *metadata + *linkSync + opened, + cleanupElements bool + } + ephemeralDir struct { + directory + refs *atomic.Uintptr + unlinkOnClose, + unlinking *atomic.Bool + closed bool + } + directorySettings struct { + fileSettings + cleanupSelf, + cleanupElements bool + } + DirectoryOption func(*directorySettings) error + directorySetter[T any] interface { + *T + setCleanupSelf(bool) + setCleanupElements(bool) + } +) + +func (ds *directorySettings) setCleanupSelf(b bool) { ds.cleanupSelf = b } +func (ds *directorySettings) setCleanupElements(b bool) { ds.cleanupElements = b } + +// UnlinkWhenEmpty causes files to unlink from their parent +// after they are considered empty and the last reference +// held by a Walk has been closed. +func UnlinkWhenEmpty[ + OT generic.OptionFunc[T], + T any, + I directorySetter[T], +](b bool, +) OT { + return func(settings *T) error { + any(settings).(I).setCleanupSelf(b) + return nil + } +} + +// UnlinkEmptyChildren sets [UnlinkWhenEmpty] +// on files created by this file. +func UnlinkEmptyChildren[ + OT generic.OptionFunc[T], + T any, + I directorySetter[T], +](b bool, +) OT { + return func(settings *T) error { + any(settings).(I).setCleanupElements(b) + return nil + } +} + +func NewDirectory(options ...DirectoryOption) (p9.QID, p9.File, error) { + var settings directorySettings + settings.metadata.initialize(p9.ModeDirectory) + if err := generic.ApplyOptions(&settings, options...); err != nil { + return p9.QID{}, nil, err + } + return newDirectory(&settings) +} + +func newDirectory(settings *directorySettings) (p9.QID, p9.File, error) { + var file p9.File = &Directory{ + fileTableSync: newFileTable(), + metadata: &settings.metadata, + linkSync: &settings.linkSync, + } + if settings.cleanupSelf { + if parent := settings.linkSync.parent; parent == nil { + err := generic.ConstError("cannot unlink self without parent file") + return p9.QID{}, nil, err + } + file = &ephemeralDir{ + directory: file, + refs: new(atomic.Uintptr), + unlinkOnClose: new(atomic.Bool), + unlinking: new(atomic.Bool), + } + } + settings.metadata.fillDefaults() + settings.metadata.incrementPath() + return settings.QID, file, nil +} + +func (dir *Directory) Walk(names []string) ([]p9.QID, p9.File, error) { + if dir.opened { + return nil, nil, fidOpenedErr + } + if len(names) == 0 { + return nil, &Directory{ + fileTableSync: dir.fileTableSync, + metadata: dir.metadata, + linkSync: dir.linkSync, + cleanupElements: dir.cleanupElements, + }, nil + } + name := names[0] + if name == parentWName { + return dir.backtrack(names[1:]) + } + child, ok := dir.load(name) + if !ok { + return nil, nil, perrors.ENOENT + } + _, clone, err := child.Walk(nil) + if err != nil { + return nil, nil, err + } + var ( + nwNames = len(names) + qids = make([]p9.QID, 1, nwNames) + attrMaskNone p9.AttrMask + ) + if qids[0], _, _, err = clone.GetAttr(attrMaskNone); err != nil { + return nil, nil, errors.Join(err, clone.Close()) + } + if noRemainder := nwNames == 1; noRemainder { + return qids, clone, nil + } + subQIDS, descendant, err := clone.Walk(names[1:]) + if err != nil { + return nil, nil, errors.Join(err, clone.Close()) + } + if err := clone.Close(); err != nil { + return nil, nil, errors.Join(err, descendant.Close()) + } + return append(qids, subQIDS...), descendant, nil +} + +func (dir *Directory) backtrack(names []string) ([]p9.QID, p9.File, error) { + var ( + qids = make([]p9.QID, 1, len(names)+1) + parent = dir.parent + ) + if dirIsRoot := parent == nil; dirIsRoot { + parent = dir + } + _, clone, err := parent.Walk(nil) + if err != nil { + return nil, nil, err + } + var attrMaskNone p9.AttrMask + if qids[0], _, _, err = clone.GetAttr(attrMaskNone); err != nil { + return nil, nil, errors.Join(err, clone.Close()) + } + if noRemainder := len(names) == 0; noRemainder { + return qids, clone, nil + } + // These could be ancestors, siblings, cousins, etc. + // depending on the remaining names. + relQIDS, relative, err := clone.Walk(names) + if err != nil { + return nil, nil, errors.Join(err, clone.Close()) + } + if err := clone.Close(); err != nil { + return nil, nil, errors.Join(err, relative.Close()) + } + return append(qids, relQIDS...), relative, nil +} + +func (dir Directory) SetAttr(valid p9.SetAttrMask, attr p9.SetAttr) error { + return dir.metadata.SetAttr(valid, attr) +} + +func (dir Directory) GetAttr(req p9.AttrMask) (p9.QID, p9.AttrMask, p9.Attr, error) { + return dir.metadata.GetAttr(req) +} + +func (dir *Directory) Open(mode p9.OpenFlags) (p9.QID, ioUnit, error) { + if dir.opened { + // TODO: [spec] correct evalue? + return p9.QID{}, noIOUnit, perrors.EBADF + } + if mode.Mode() != p9.ReadOnly { + return p9.QID{}, noIOUnit, perrors.EINVAL + } + dir.opened = true + return dir.QID, noIOUnit, nil +} + +func (dir *Directory) Link(file p9.File, name string) error { + if !dir.exclusiveStore(name, file) { + return perrors.EEXIST // TODO: spec; evalue + } + return nil +} + +func (dir *Directory) UnlinkAt(name string, _ uint32) error { + if !dir.delete(name) { + return perrors.ENOENT // TODO: spec; evalue + } + return nil +} + +func (dir *Directory) Mkdir(name string, permissions p9.FileMode, uid p9.UID, gid p9.GID) (p9.QID, error) { + uid, gid, err := mkPreamble(dir, name, uid, gid) + if err != nil { + return p9.QID{}, err + } + qid, newDir, err := NewDirectory( + WithPath[DirectoryOption](dir.ninePath), + WithPermissions[DirectoryOption](permissions), + WithUID[DirectoryOption](uid), + WithGID[DirectoryOption](gid), + WithParent[DirectoryOption](dir, name), + UnlinkWhenEmpty[DirectoryOption](dir.cleanupElements), + UnlinkEmptyChildren[DirectoryOption](dir.cleanupElements), + WithoutRename[DirectoryOption](dir.linkSync.renameDisabled), + ) + if err == nil { + err = dir.Link(newDir, name) + } + return qid, err +} + +func (dir *Directory) Readdir(offset uint64, count uint32) (p9.Dirents, error) { + return dir.to9Ents(offset, count) +} + +func (dir *Directory) Rename(newDir p9.File, newName string) error { + return dir.linkSync.rename(dir, newDir, newName) +} + +func (dir *Directory) RenameAt(oldName string, newDir p9.File, newName string) error { + return dir.linkSync.renameAt(dir, newDir, oldName, newName) +} + +func (dir *Directory) Renamed(newDir p9.File, newName string) { + dir.linkSync.Renamed(newDir, newName) +} + +func (ed *ephemeralDir) Attach() (p9.File, error) { return ed, nil } + +func (ed *ephemeralDir) Walk(names []string) ([]p9.QID, p9.File, error) { + qids, file, err := ed.directory.Walk(names) + if len(names) == 0 { + refs := ed.refs + refs.Add(1) + file = &ephemeralDir{ + directory: file, + refs: refs, + unlinkOnClose: ed.unlinkOnClose, + unlinking: ed.unlinking, + } + } + return qids, file, err +} + +func (ed *ephemeralDir) Close() error { + if ed.closed { + return perrors.EBADF + } + ed.closed = true + const decriment = ^uintptr(0) + if active := ed.refs.Add(decriment); active != 0 || + !ed.unlinkOnClose.Load() || + ed.unlinking.Load() { + return nil + } + ed.unlinking.Store(true) + return ed.unlinkSelf() +} + +func (ed *ephemeralDir) Link(file p9.File, name string) error { + dir := ed.directory.(*Directory) + if err := dir.Link(file, name); err != nil { + return err + } + ed.unlinkOnClose.Store(false) + return nil +} + +func (ed *ephemeralDir) UnlinkAt(name string, _ uint32) error { + var ( + dir = ed.directory.(*Directory) + table = dir.fileTableSync + ) + table.mu.Lock() + defer table.mu.Unlock() + if !table.deleteLocked(name) { + return perrors.ENOENT // TODO: spec; evalue + } + if table.lengthLocked() == 0 { + ed.unlinkOnClose.Store(true) + } + return nil +} + +func (ed *ephemeralDir) unlinkSelf() error { + var ( + dir = ed.directory.(*Directory) + link = dir.linkSync + ) + return unlinkChildSync(link) +} + +func childExists(fsys p9.File, name string) (bool, error) { + _, file, err := fsys.Walk([]string{name}) + if err == nil { + if err = file.Close(); err != nil { + err = fmt.Errorf("could not close child: %w", err) + } + return true, err + } + if errors.Is(err, perrors.ENOENT) { + err = nil + } + return false, err +} + +// If any passed in IDs are invalid, +// they will be subsisted with values from fsys. +func maybeInheritIDs(fsys p9.File, uid p9.UID, gid p9.GID) (p9.UID, p9.GID, error) { + var ( + getUID = !uid.Ok() + getGID = !gid.Ok() + ) + if getAttrs := getUID || getGID; !getAttrs { + return uid, gid, nil + } + want := p9.AttrMask{ + UID: getUID, + GID: getGID, + } + _, _, attr, err := fsys.GetAttr(want) + if err != nil { + return p9.NoUID, p9.NoGID, err + } + if getUID { + uid = attr.UID + } + if getGID { + gid = attr.GID + } + return uid, gid, nil +} diff --git a/internal/filesystem/9p/doc.go b/internal/filesystem/9p/doc.go new file mode 100644 index 00000000..e4afa650 --- /dev/null +++ b/internal/filesystem/9p/doc.go @@ -0,0 +1,3 @@ +// Package p9 implements file systems for the +// Plan 9 File Protocol. +package p9 diff --git a/internal/filesystem/9p/guest.go b/internal/filesystem/9p/guest.go new file mode 100644 index 00000000..e34db2ba --- /dev/null +++ b/internal/filesystem/9p/guest.go @@ -0,0 +1,113 @@ +package p9 + +import ( + "errors" + + "github.com/djdv/go-filesystem-utils/internal/generic" + perrors "github.com/djdv/p9/errors" + "github.com/djdv/p9/p9" +) + +type ( + GuestFile struct { + directory + makeMountPointFn MakeMountPointFunc + } + guestSettings struct { + directorySettings + } + GuestOption func(*guestSettings) error + // MakeMountPointFunc should handle file creation operations + // for files representing mount points. + // The file `mode` will contain file type bits. + MakeMountPointFunc func(parent p9.File, name string, + mode p9.FileMode, uid p9.UID, gid p9.GID, + ) (p9.QID, p9.File, error) + detacher interface { + detach() error + } +) + +func NewGuestFile(makeMountPointFn MakeMountPointFunc, + options ...GuestOption, +) (p9.QID, *GuestFile, error) { + var settings guestSettings + settings.metadata.initialize(p9.ModeDirectory) + if err := generic.ApplyOptions(&settings, options...); err != nil { + return p9.QID{}, nil, err + } + qid, directory, err := newDirectory(&settings.directorySettings) + if err != nil { + return p9.QID{}, nil, err + } + return qid, &GuestFile{ + directory: directory, + makeMountPointFn: makeMountPointFn, + }, nil +} + +func (gf *GuestFile) Walk(names []string) ([]p9.QID, p9.File, error) { + qids, file, err := gf.directory.Walk(names) + if len(names) == 0 { + file = &GuestFile{ + directory: file, + makeMountPointFn: gf.makeMountPointFn, + } + } + return qids, file, err +} + +// TODO: stub out [Link] too? +func (gf *GuestFile) Mkdir(name string, permissions p9.FileMode, uid p9.UID, gid p9.GID) (p9.QID, error) { + uid, gid, err := mkPreamble(gf, name, uid, gid) + if err != nil { + return p9.QID{}, err + } + mode := permissions | p9.ModeDirectory + qid, file, err := gf.makeMountPointFn(gf, name, + mode, uid, gid) + if err != nil { + return p9.QID{}, errors.Join(perrors.EACCES, err) + } + return qid, gf.Link(file, name) +} + +func (gf *GuestFile) Create(name string, flags p9.OpenFlags, permissions p9.FileMode, + uid p9.UID, gid p9.GID, +) (p9.File, p9.QID, uint32, error) { + return createViaMknod(gf, name, flags, permissions, uid, gid) +} + +func (gf *GuestFile) Mknod(name string, mode p9.FileMode, + _, _ uint32, uid p9.UID, gid p9.GID, +) (p9.QID, error) { + uid, gid, err := mkPreamble(gf, name, uid, gid) + if err != nil { + return p9.QID{}, err + } + mode |= p9.ModeRegular + qid, file, err := gf.makeMountPointFn(gf, name, + mode, uid, gid) + if err != nil { + return p9.QID{}, err + } + return qid, gf.Link(file, name) +} + +func (gf *GuestFile) UnlinkAt(name string, flags uint32) error { + var ( + dir = gf.directory + _, file, err = dir.Walk([]string{name}) + ) + if err != nil { + return err + } + // NOTE: Always attempt both operations, + // regardless of error from preceding operation. + var dErr error + if target, ok := file.(detacher); ok { + dErr = target.detach() + } + uErr := dir.UnlinkAt(name, flags) + return errors.Join(dErr, uErr) +} diff --git a/internal/filesystem/9p/host.go b/internal/filesystem/9p/host.go new file mode 100644 index 00000000..5b232a40 --- /dev/null +++ b/internal/filesystem/9p/host.go @@ -0,0 +1,92 @@ +package p9 + +import ( + "errors" + + "github.com/djdv/go-filesystem-utils/internal/filesystem" + "github.com/djdv/go-filesystem-utils/internal/generic" + perrors "github.com/djdv/p9/errors" + "github.com/djdv/p9/p9" +) + +type ( + HostFile struct { + directory + makeGuestFn MakeGuestFunc + } + hosterSettings struct { + directorySettings + } + HosterOption func(*hosterSettings) error + // MakeGuestFunc should handle file creation operations + // for files representing a [filesystem.ID]. + // The file `mode` will contain file type bits. + MakeGuestFunc func(parent p9.File, guest filesystem.ID, + mode p9.FileMode, + uid p9.UID, gid p9.GID) (p9.QID, p9.File, error) +) + +func NewHostFile(makeGuestFn MakeGuestFunc, + options ...HosterOption, +) (p9.QID, *HostFile, error) { + var settings hosterSettings + settings.metadata.initialize(p9.ModeDirectory) + if err := generic.ApplyOptions(&settings, options...); err != nil { + return p9.QID{}, nil, err + } + qid, directory, err := newDirectory(&settings.directorySettings) + if err != nil { + return p9.QID{}, nil, err + } + return qid, &HostFile{ + directory: directory, + makeGuestFn: makeGuestFn, + }, nil +} + +func (hd *HostFile) Walk(names []string) ([]p9.QID, p9.File, error) { + qids, file, err := hd.directory.Walk(names) + if len(names) == 0 { + file = &HostFile{ + directory: file, + makeGuestFn: hd.makeGuestFn, + } + } + return qids, file, err +} + +func (hd *HostFile) Mkdir(name string, permissions p9.FileMode, uid p9.UID, gid p9.GID) (p9.QID, error) { + uid, gid, err := mkPreamble(hd, name, uid, gid) + if err != nil { + return p9.QID{}, err + } + mode := permissions | p9.ModeDirectory + qid, file, err := hd.makeGuestFn(hd, filesystem.ID(name), + mode, uid, gid) + if err != nil { + return p9.QID{}, errors.Join(perrors.EACCES, err) + } + return qid, hd.Link(file, name) +} + +func (hf *HostFile) Create(name string, flags p9.OpenFlags, permissions p9.FileMode, + uid p9.UID, gid p9.GID, +) (p9.File, p9.QID, uint32, error) { + return createViaMknod(hf, name, flags, permissions, uid, gid) +} + +func (hf *HostFile) Mknod(name string, mode p9.FileMode, + _, _ uint32, uid p9.UID, gid p9.GID, +) (p9.QID, error) { + uid, gid, err := mkPreamble(hf, name, uid, gid) + if err != nil { + return p9.QID{}, err + } + mode |= p9.ModeRegular + qid, file, err := hf.makeGuestFn(hf, filesystem.ID(name), + mode, uid, gid) + if err != nil { + return p9.QID{}, errors.Join(perrors.EACCES, err) + } + return qid, hf.Link(file, name) +} diff --git a/internal/filesystem/9p/link.go b/internal/filesystem/9p/link.go new file mode 100644 index 00000000..8d4f3fa2 --- /dev/null +++ b/internal/filesystem/9p/link.go @@ -0,0 +1,107 @@ +package p9 + +import ( + "sync" + + "github.com/djdv/go-filesystem-utils/internal/generic" + perrors "github.com/djdv/p9/errors" + "github.com/djdv/p9/p9" +) + +type ( + link struct { + parent p9.File + child string + } + RenamedFunc func(old, new string) + linkSync struct { + renamedFn RenamedFunc + link + mu sync.Mutex + renameDisabled bool + } + linkSetter[T any] interface { + *T + setParent(p9.File, string) + disableRename(bool) + setRenamedFunc(RenamedFunc) + } +) + +func (ls *linkSync) setParent(parent p9.File, child string) { ls.parent = parent; ls.child = child } +func (ls *linkSync) disableRename(disabled bool) { ls.renameDisabled = disabled } +func (ls *linkSync) setRenamedFunc(fn RenamedFunc) { ls.renamedFn = fn } + +func WithParent[ + OT generic.OptionFunc[T], + T any, + I linkSetter[T], +](parent p9.File, child string, +) OT { + return func(link *T) error { + any(link).(I).setParent(parent, child) + return nil + } +} + +// WithoutRename causes rename operations +// to return an error when called. +func WithoutRename[ + OT generic.OptionFunc[T], + T any, + I linkSetter[T], +](disabled bool, +) OT { + return func(link *T) error { + any(link).(I).disableRename(disabled) + return nil + } +} + +// WithRenamedFunc provides a callback +// which is called after a successful rename operation. +func WithRenamedFunc[ + OT generic.OptionFunc[T], + T any, + I linkSetter[T], +](fn RenamedFunc, +) OT { + return func(link *T) error { + any(link).(I).setRenamedFunc(fn) + return nil + } +} + +func (ls *linkSync) rename(file, newDir p9.File, newName string) error { + ls.mu.Lock() + defer ls.mu.Unlock() + if ls.renameDisabled { + return perrors.EACCES + } + parent := ls.parent + if parent == nil { + // We allow this for now, but ENOENT + // would also make sense here (on POSIX). + return newDir.Link(file, newName) + } + return rename(file, parent, newDir, ls.child, newName) +} + +func (ls *linkSync) renameAt(oldDir, newDir p9.File, oldName, newName string) error { + ls.mu.Lock() + defer ls.mu.Unlock() + if ls.renameDisabled { + return perrors.EACCES + } + return renameAt(oldDir, newDir, oldName, newName) +} + +func (ls *linkSync) Renamed(newDir p9.File, newName string) { + ls.mu.Lock() + defer ls.mu.Unlock() + if renamed := ls.renamedFn; renamed != nil { + defer renamed(ls.link.child, newName) + } + ls.link.parent = newDir + ls.link.child = newName +} diff --git a/internal/filesystem/9p/listener.go b/internal/filesystem/9p/listener.go new file mode 100644 index 00000000..8bc9e2c4 --- /dev/null +++ b/internal/filesystem/9p/listener.go @@ -0,0 +1,1058 @@ +package p9 + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path" + "path/filepath" + "runtime" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/djdv/go-filesystem-utils/internal/generic" + p9net "github.com/djdv/go-filesystem-utils/internal/net/9p" + perrors "github.com/djdv/p9/errors" + "github.com/djdv/p9/fsimpl/templatefs" + "github.com/djdv/p9/p9" + "github.com/multiformats/go-multiaddr" + manet "github.com/multiformats/go-multiaddr/net" +) + +type ( + Listener struct { + directory + listenerShared + } + listenerSettings struct { + directorySettings + channelSettings + } + ListenerOption func(*listenerSettings) error + listenerShared struct { + emitter *chanEmitter[manet.Listener] + path ninePath + cleanupEmpties bool + } + protocolDir struct { + directory + *linkSync + *listenerShared + protocol *multiaddr.Protocol + } + valueDir struct { + directory + *linkSync + *listenerShared + component *multiaddr.Component + connDirMu *sync.Mutex + connDirPtr **connDir + connIndex *atomic.Uintptr + } + listenerFile struct { + templatefs.NoopFile + *metadata + Listener manet.Listener + *linkSync + io.ReaderAt + openFlags + } + listenerCloser struct { + manet.Listener + closeFn func() error + } + connTracker struct { + parent *valueDir + manet.Listener + } + connDir struct { + directory + path ninePath + } + connFile struct { + templatefs.NoopFile + *metadata + trackedConn + io.ReaderAt + *linkSync + connID uintptr + openFlags + } + trackedConn interface { + manet.Conn + p9net.TrackedIO + } + connCloser struct { + trackedConn + closeFn func() error + } + ConnInfo struct { + LastRead time.Time `json:"lastRead"` + LastWrite time.Time `json:"lastWrite"` + Local multiaddr.Multiaddr `json:"local"` + Remote multiaddr.Multiaddr `json:"remote"` + ID uintptr `json:"#"` + } +) + +const ( + listenerFileName = "listener" + connectionsFileName = "connections" +) + +func NewListener(ctx context.Context, options ...ListenerOption) (p9.QID, *Listener, <-chan manet.Listener, error) { + var settings listenerSettings + settings.metadata.initialize(p9.ModeDirectory) + if err := generic.ApplyOptions(&settings, options...); err != nil { + return p9.QID{}, nil, nil, err + } + settings.linkSync.renameDisabled = true + qid, directory, err := newDirectory(&settings.directorySettings) + if err != nil { + return p9.QID{}, nil, nil, err + } + var ( + emitter = makeChannelEmitter[manet.Listener]( + ctx, + settings.channelSettings.buffer, + ) + listeners = emitter.ch + listener = &Listener{ + directory: directory, + listenerShared: listenerShared{ + path: settings.metadata.ninePath, + emitter: emitter, + cleanupEmpties: settings.cleanupElements, + }, + } + ) + return qid, listener, listeners, nil +} + +// TODO: [Ame] English. +// Listen tries to listen on the provided [Multiaddr]. +// If successful, the [Multiaddr] is mapped as a directory, +// inheriting permissions from parent directories all the way down. +// The passed permissions are used for the final API file. +func Listen(listener p9.File, maddr multiaddr.Multiaddr, permissions p9.FileMode) error { + var ( + _, names = splitMaddr(maddr) + uid = p9.NoUID + gid = p9.NoGID + ) + valueDir, err := MkdirAll(listener, names, permissions, uid, gid) + if err != nil { + return err + } + permissions ^= ExecuteOther | ExecuteGroup | ExecuteUser + _, err = valueDir.Mknod(listenerFileName, permissions, 0, 0, p9.NoUID, p9.NoGID) + if cErr := valueDir.Close(); cErr != nil { + return errors.Join(err, cErr) + } + return err +} + +// GetListeners returns a slice of maddrs that correspond to +// active listeners contained within the `listener` file. +func GetListeners(listener p9.File) ([]multiaddr.Multiaddr, error) { + var ( + ctx, cancel = context.WithCancel(context.Background()) + results = getListeners(ctx, listener) + ) + defer cancel() + return aggregateResults(cancel, results) +} + +func getListeners(ctx context.Context, listener p9.File) <-chan maddrResult { + return mapDirPipeline(ctx, listener, listenerPipeline) +} + +func listenerPipeline(ctx context.Context, + listener p9.File, + wg *sync.WaitGroup, results chan<- maddrResult, +) { + defer wg.Done() + processFile := func(result fileResult) { + defer wg.Done() + if err := result.error; err != nil { + sendResult(ctx, results, maddrResult{error: err}) + return + } + var ( + listenerFile = result.value + maddr, err = parseListenerFile(listenerFile) + ) + if cErr := listenerFile.Close(); cErr != nil { + err = errors.Join(err, cErr) + } + sendResult(ctx, results, maddrResult{value: maddr, error: err}) + } + for result := range findFiles(ctx, listener, listenerFileName) { + wg.Add(1) + go processFile(result) + } +} + +func parseListenerFile(file p9.File) (multiaddr.Multiaddr, error) { + maddrBytes, err := ReadAll(file) + if err != nil { + return nil, err + } + return multiaddr.NewMultiaddr(string(maddrBytes)) +} + +// GetConnections returns a slice of info that corresponds to +// active connections contained within the `listener` file. +func GetConnections(listener p9.File) ([]ConnInfo, error) { + var ( + ctx, cancel = context.WithCancel(context.Background()) + results = getConnections(ctx, listener) + ) + defer cancel() + return aggregateResults(cancel, results) +} + +func getConnections(ctx context.Context, listener p9.File) <-chan connInfoResult { + return mapDirPipeline(ctx, listener, connectionPipeline) +} + +func connectionPipeline(ctx context.Context, + listener p9.File, + wg *sync.WaitGroup, results chan<- connInfoResult, +) { + defer wg.Done() + processFile := func(result fileResult) { + defer wg.Done() + if err := result.error; err != nil { + sendResult(ctx, results, connInfoResult{error: err}) + return + } + connDir := result.value + defer func() { + if err := connDir.Close(); err != nil { + sendResult(ctx, results, connInfoResult{error: err}) + } + }() + for fileRes := range flattenDir(ctx, connDir) { + if err := fileRes.error; err != nil { + sendResult(ctx, results, connInfoResult{error: err}) + continue + } + var ( + connFile = fileRes.value + info, err = parseConnFile(connFile) + ) + if cErr := connFile.Close(); cErr != nil { + err = errors.Join(err, cErr) + } + sendResult(ctx, results, connInfoResult{value: info, error: err}) + } + } + for result := range findFiles(ctx, listener, connectionsFileName) { + wg.Add(1) + go processFile(result) + } +} + +func parseConnFile(file p9.File) (ConnInfo, error) { + connData, err := ReadAll(file) + if err != nil { + return ConnInfo{}, err + } + var info ConnInfo + return info, json.Unmarshal(connData, &info) +} + +func (ld *Listener) Walk(names []string) ([]p9.QID, p9.File, error) { + qids, file, err := ld.directory.Walk(names) + if len(names) == 0 { + file = &Listener{ + directory: file, + listenerShared: ld.listenerShared, + } + } + return qids, file, err +} + +func (ld *Listener) Link(file p9.File, name string) error { + var ( + _, pOk = file.(*protocolDir) + _, vOk = file.(*valueDir) + ok = pOk || vOk + ) + if !ok { + return fmt.Errorf("%w - unexpected file type", perrors.EACCES) + } + return ld.directory.Link(file, name) +} + +func (ld *Listener) Mkdir(name string, permissions p9.FileMode, uid p9.UID, gid p9.GID) (p9.QID, error) { + protocol, err := getProtocol(name) + if err != nil { + return p9.QID{}, fmt.Errorf("%w - %s", perrors.EIO, err) + } + qid, directory, link, err := ld.mkdir(ld, + name, permissions, uid, gid, + ) + if err != nil { + return p9.QID{}, err + } + protoDir := &protocolDir{ + listenerShared: &ld.listenerShared, + linkSync: link, + directory: directory, + protocol: protocol, + } + return qid, ld.directory.Link(protoDir, name) +} + +func (ls *listenerShared) mkdir(directory p9.File, name string, + permissions p9.FileMode, uid p9.UID, gid p9.GID, +) (p9.QID, p9.File, *linkSync, error) { + uid, gid, err := mkPreamble(directory, name, uid, gid) + if err != nil { + return p9.QID{}, nil, nil, err + } + var ( + cleanup = ls.cleanupEmpties + settings = directorySettings{ + fileSettings: fileSettings{ + linkSync: linkSync{ + link: link{ + parent: directory, + child: name, + }, + renameDisabled: true, + }, + }, + cleanupSelf: cleanup, + cleanupElements: cleanup, + } + ) + settings.metadata.initialize( + p9.ModeDirectory | permissions.Permissions(), + ) + settings.metadata.ninePath = ls.path + settings.metadata.UID, settings.metadata.GID = uid, gid + qid, file, err := newDirectory(&settings) + return qid, file, &settings.linkSync, err +} + +func (pd *protocolDir) Walk(names []string) ([]p9.QID, p9.File, error) { + qids, file, err := pd.directory.Walk(names) + if len(names) == 0 { + file = &protocolDir{ + directory: file, + listenerShared: pd.listenerShared, + linkSync: pd.linkSync, + protocol: pd.protocol, + } + } + return qids, file, err +} + +func (pd *protocolDir) Renamed(newDir p9.File, newName string) { + pd.directory.Renamed(newDir, newName) +} + +func (pd *protocolDir) Link(file p9.File, name string) error { + if _, ok := file.(*valueDir); !ok { + return fmt.Errorf("%w - unexpected file type", perrors.EACCES) + } + return pd.directory.Link(file, name) +} + +func (pd *protocolDir) Mkdir(name string, permissions p9.FileMode, uid p9.UID, gid p9.GID) (p9.QID, error) { + var component *multiaddr.Component + if !pd.protocol.Path { + var ( + err error + protocol = pd.protocol.Name + ) + if component, err = multiaddr.NewComponent(protocol, name); err != nil { + return p9.QID{}, err + } + } + qid, directory, link, err := pd.mkdir(pd, + name, permissions, uid, gid, + ) + if err != nil { + return p9.QID{}, err + } + newDir := &valueDir{ + listenerShared: pd.listenerShared, + linkSync: link, + directory: directory, + component: component, + connDirMu: new(sync.Mutex), + connDirPtr: new(*connDir), + connIndex: new(atomic.Uintptr), + } + return qid, pd.directory.Link(newDir, name) +} + +func (vd *valueDir) Walk(names []string) ([]p9.QID, p9.File, error) { + qids, file, err := vd.directory.Walk(names) + if len(names) == 0 { + file = &valueDir{ + directory: file, + listenerShared: vd.listenerShared, + linkSync: vd.linkSync, + component: vd.component, + connDirMu: vd.connDirMu, + connDirPtr: vd.connDirPtr, + connIndex: vd.connIndex, + } + } + return qids, file, err +} + +func (vd *valueDir) Renamed(newDir p9.File, newName string) { + vd.linkSync.Renamed(newDir, newName) +} + +func (vd *valueDir) Link(file p9.File, name string) error { + var ( + _, pOk = file.(*protocolDir) + _, vOk = file.(*valueDir) + _, cOk = file.(*connDir) + _, fOk = file.(*listenerFile) + ok = pOk || vOk || cOk || fOk + ) + if !ok { + return fmt.Errorf("%w - unexpected file type", perrors.EACCES) + } + return vd.directory.Link(file, name) +} + +func (vd *valueDir) Mkdir(name string, permissions p9.FileMode, uid p9.UID, gid p9.GID) (p9.QID, error) { + if isPathType := vd.component == nil; isPathType { + qid, directory, link, err := vd.mkdir(vd, + name, permissions, uid, gid, + ) + if err != nil { + return p9.QID{}, err + } + valueDir := &valueDir{ + directory: directory, + listenerShared: vd.listenerShared, + linkSync: link, + connDirMu: vd.connDirMu, + connDirPtr: vd.connDirPtr, + connIndex: vd.connIndex, + } + return qid, vd.directory.Link(valueDir, name) + } + protocol, err := getProtocol(name) + if err != nil { + return p9.QID{}, fmt.Errorf("%w - %s", perrors.EIO, err) + } + qid, directory, link, err := vd.mkdir(vd, + name, permissions, uid, gid, + ) + if err != nil { + return p9.QID{}, err + } + protoDir := &protocolDir{ + listenerShared: vd.listenerShared, + linkSync: link, + directory: directory, + protocol: protocol, + } + return qid, vd.directory.Link(protoDir, name) +} + +func (vd *valueDir) Create(name string, flags p9.OpenFlags, + permissions p9.FileMode, uid p9.UID, gid p9.GID, +) (p9.File, p9.QID, uint32, error) { + return createViaMknod(vd, name, flags, permissions, uid, gid) +} + +func (vd *valueDir) Mknod(name string, mode p9.FileMode, + major, minor uint32, uid p9.UID, gid p9.GID, +) (p9.QID, error) { + if name != listenerFileName { + // TODO: add error message + return p9.QID{}, perrors.EACCES + } + maddr, err := vd.assemble() + if err != nil { + return p9.QID{}, err + } + if uid, gid, err = mkPreamble(vd, listenerFileName, uid, gid); err != nil { + return p9.QID{}, err + } + listener, err := vd.listen(maddr, mode) + if err != nil { + return p9.QID{}, err + } + var ( + closeOnce, + unlinkOnce sync.Once + unlinked atomic.Bool + netErr error + fileListener = &listenerCloser{ + Listener: listener, + closeFn: func() error { + closeOnce.Do(func() { + unlinked.Store(true) + netErr = listener.Close() + }) + return netErr + }, + } + qid, file = vd.newListenerFile(mode, uid, gid, fileListener) + ) + if err := vd.Link(file, name); err != nil { + return p9.QID{}, errors.Join(err, listener.Close()) + } + var ( + link = file.linkSync + unlinkerListener = &listenerCloser{ + Listener: listener, + closeFn: func() error { + unlinkOnce.Do(func() { + if !unlinked.Load() { + unlinkChildSync(link) + } + }) + return fileListener.closeFn() + }, + } + ) + if err := vd.emitter.emit(unlinkerListener); err != nil { + return p9.QID{}, errors.Join(err, unlinkerListener.Close()) + } + return qid, nil +} + +func (vd *valueDir) listen(maddr multiaddr.Multiaddr, permissions p9.FileMode) (manet.Listener, error) { + udsPath, err := maybeGetUDSPath(maddr) + if err != nil { + return nil, err + } + var cleanup func() error + if len(udsPath) > 0 { + hostPermissions := permissions.Permissions().OSMode() + if cleanup, err = maybeMakeParentDir(udsPath, hostPermissions); err != nil { + return nil, err + } + } + listener, err := manet.Listen(maddr) + if err != nil { + if cleanup != nil { + return nil, errors.Join(err, cleanup()) + } + return nil, err + } + var ( + closeFn = func() error { + err := listener.Close() + if cleanup != nil { + if cErr := cleanup(); cErr != nil { + return errors.Join(err, cErr) + } + } + return err + } + trackingListener = &connTracker{ + parent: vd, + Listener: &listenerCloser{ + Listener: listener, + closeFn: closeFn, + }, + } + ) + return trackingListener, nil +} + +func (vd *valueDir) newListenerFile( + permissions p9.FileMode, uid p9.UID, gid p9.GID, + listener manet.Listener, +) (p9.QID, *listenerFile) { + var ( + path = vd.path + metadata metadata + ) + metadata.initialize( + p9.ModeRegular | permissions.Permissions(), + ) + metadata.ninePath = path + metadata.UID, metadata.GID = uid, gid + metadata.Size = uint64(len(listener.Multiaddr().String())) + listenerFile := &listenerFile{ + metadata: &metadata, + linkSync: &linkSync{ + link: link{ + parent: vd, + child: listenerFileName, + }, + renameDisabled: true, + }, + Listener: listener, + } + metadata.fillDefaults() + metadata.incrementPath() + return metadata.QID, listenerFile +} + +func (vd *valueDir) UnlinkAt(name string, flags uint32) error { + directory := vd.directory + _, file, err := directory.Walk([]string{name}) + if err != nil { + return err + } + // NOTE: non-fs errors are ignored in this operation. + if lFile, ok := file.(*listenerFile); ok { + lFile.Listener.Close() + } + if _, ok := file.(*connDir); ok { + // HACK: we can't compare this file + // and our vd.*file (because our Walk + // gives a unique instance). So we just + // assume it's the one we constructed. + // If we expect files to move around + // a UUID could be placed on the connDir. + // Or a deconstructor could be paired with it + // (similar to how ephemeral dirs ref count works). + vd.connDirMu.Lock() + *vd.connDirPtr = nil + vd.connDirMu.Unlock() + } + return errors.Join( + file.Close(), + directory.UnlinkAt(name, flags), + ) +} + +func (pd *valueDir) assemble() (multiaddr.Multiaddr, error) { + tail := pd.component + if isPath := tail == nil; isPath { + return pd.assemblePath() + } + var components []multiaddr.Multiaddr + for current := pd.linkSync.parent; current != nil; { + switch v := current.(type) { + case *protocolDir: + current = v.linkSync.parent + case *valueDir: + components = append(components, v.component) + current = v.linkSync.parent + default: + current = nil + } + } + reverse(components) + return multiaddr.Join(append(components, tail)...), nil +} + +func (vd *valueDir) assemblePath() (multiaddr.Multiaddr, error) { + var ( + link = vd.linkSync.link + names = []string{link.child} + current = link.parent + ) + for intermediate, ok := current.(*valueDir); ok; intermediate, ok = current.(*valueDir) { + current = intermediate.linkSync.parent + names = append(names, intermediate.linkSync.child) + } + protoDir, ok := current.(*protocolDir) + if !ok { + return nil, fmt.Errorf("%T is not a protocol directory", current) + } + reverse(names) + var ( + prefix = "/" + protoDir.protocol.Name + components = append([]string{prefix}, names...) + maddrString = path.Join(components...) + ) + return multiaddr.NewMultiaddr(maddrString) +} + +func (vd *valueDir) getConnDir() (*connDir, error) { + vd.connDirMu.Lock() + defer vd.connDirMu.Unlock() + if cd := *vd.connDirPtr; cd != nil { + _, f, err := cd.Walk(nil) + if err != nil { + return nil, err + } + return f.(*connDir), nil + } + uid, gid, err := mkPreamble(vd, connectionsFileName, p9.NoUID, p9.NoGID) + if err != nil { + return nil, err + } + const permissions = ExecuteOther | + ExecuteGroup | WriteGroup | ReadGroup | + ExecuteUser | WriteUser | ReadUser + cleanup := vd.cleanupEmpties + _, dir, err := NewDirectory( + WithPath[DirectoryOption](vd.path), + WithPermissions[DirectoryOption](permissions), + WithUID[DirectoryOption](uid), + WithGID[DirectoryOption](gid), + WithParent[DirectoryOption](vd, connectionsFileName), + UnlinkWhenEmpty[DirectoryOption](cleanup), + UnlinkEmptyChildren[DirectoryOption](cleanup), + ) + if err != nil { + return nil, err + } + cd := &connDir{ + directory: dir, + path: vd.path, + } + _, f, err := cd.Walk(nil) + if err != nil { + return nil, err + } + vd.connDirPtr = &cd + vd.Link(cd, connectionsFileName) + return f.(*connDir), nil +} + +func (cd *connDir) Walk(names []string) ([]p9.QID, p9.File, error) { + qids, file, err := cd.directory.Walk(names) + if len(names) == 0 { + file = &connDir{ + directory: file, + path: cd.path, + } + } + return qids, file, err +} + +func (cd *connDir) Link(file p9.File, name string) error { + if _, ok := file.(*connFile); !ok { + return fmt.Errorf("%w - unexpected file type", perrors.EACCES) + } + return cd.directory.Link(file, name) +} + +func (cd *connDir) UnlinkAt(name string, flags uint32) error { + directory := cd.directory + _, file, err := directory.Walk([]string{name}) + if err != nil { + return err + } + if cFile, ok := file.(*connFile); ok { + cFile.trackedConn.Close() + } + return errors.Join( + file.Close(), + directory.UnlinkAt(name, flags), + ) +} + +func (cd *connDir) newConnFile(name string, id uintptr, permissions p9.FileMode, uid p9.UID, gid p9.GID, + conn trackedConn, +) (p9.QID, *connFile, error) { + uid, gid, err := maybeInheritIDs(cd, uid, gid) + if err != nil { + return p9.QID{}, nil, err + } + var ( + path = cd.path + metadata metadata + ) + metadata.initialize( + p9.ModeRegular | permissions.Permissions(), + ) + metadata.ninePath = path + metadata.UID, metadata.GID = uid, gid + connFile := &connFile{ + connID: id, + trackedConn: conn, + metadata: &metadata, + linkSync: &linkSync{ + link: link{ + parent: cd, + child: name, + }, + renameDisabled: true, + }, + } + metadata.fillDefaults() + metadata.incrementPath() + return metadata.QID, connFile, nil +} + +func (lf *listenerFile) Walk(names []string) ([]p9.QID, p9.File, error) { + if len(names) > 0 { + return nil, nil, perrors.ENOTDIR + } + if lf.opened() { + return nil, nil, fidOpenedErr + } + return nil, &listenerFile{ + Listener: lf.Listener, + metadata: lf.metadata, + linkSync: lf.linkSync, + }, nil +} + +func (lf *listenerFile) SetAttr(valid p9.SetAttrMask, attr p9.SetAttr) error { + return lf.metadata.SetAttr(valid, attr) +} + +func (lf *listenerFile) GetAttr(req p9.AttrMask) (p9.QID, p9.AttrMask, p9.Attr, error) { + return lf.metadata.GetAttr(req) +} + +func (lf *listenerFile) Open(mode p9.OpenFlags) (p9.QID, ioUnit, error) { + if lf.opened() { + return p9.QID{}, 0, perrors.EBADF + } + lf.openFlags = lf.withOpenedFlag(mode) + return lf.QID, 0, nil +} + +func (lf *listenerFile) Close() error { + lf.openFlags = 0 + lf.ReaderAt = nil + return nil +} + +func (lf *listenerFile) ReadAt(p []byte, offset int64) (int, error) { + reader := lf.ReaderAt + if reader == nil { + if !lf.canRead() { + return -1, perrors.EBADF + } + data := lf.Listener.Multiaddr().String() + reader = strings.NewReader(data) + lf.ReaderAt = reader + } + return reader.ReadAt(p, offset) +} + +func (lc *listenerCloser) Close() error { return lc.closeFn() } + +func getProtocol(name string) (*multiaddr.Protocol, error) { + protocol := multiaddr.ProtocolWithName(name) + if protocol.Code == 0 { + return nil, fmt.Errorf("\"%s\" not a valid protocol", name) + } + return &protocol, nil +} + +// reverse is a generic adaption of gopls' `slice.reverse`. +// Named just for clarity. +func reverse[T any](slc []T) { + for i, j := 0, len(slc)-1; i < j; i, j = i+1, j-1 { + slc[i], slc[j] = slc[j], slc[i] + } +} + +// maybeGetUDSPath will return the first +// Unix Domain Socket path within maddr, if any. +// The returned path should be a suitable file path. +func maybeGetUDSPath(maddr multiaddr.Multiaddr) (string, error) { + for _, protocol := range maddr.Protocols() { + if protocol.Code == multiaddr.P_UNIX { + udsPath, err := maddr.ValueForProtocol(protocol.Code) + if err != nil { + return "", err + } + if runtime.GOOS == "windows" { + // `/C:\path` -> `C:\path` + return strings.TrimPrefix(udsPath, `/`), nil + } + return udsPath, nil + } + } + return "", nil +} + +// maybeMakeParentDir may create a parent directory +// for path, if one does not exist. And `rmDir` will remove it. +// If path's parent does exist, `rmDir` will be nil. +func maybeMakeParentDir(path string, permissions fs.FileMode) (rmDir func() error, _ error) { + socketDir := filepath.Dir(path) + if err := os.Mkdir(socketDir, permissions); err != nil { + if errors.Is(err, fs.ErrExist) { + return nil, nil + } + return nil, err + } + return func() error { + return os.Remove(socketDir) + }, nil +} + +func splitMaddr(maddr multiaddr.Multiaddr) (components []*multiaddr.Component, names []string) { + multiaddr.ForEach(maddr, func(c multiaddr.Component) bool { + components = append(components, &c) + names = append(names, strings.Split(c.String(), "/")[1:]...) + return true + }) + return +} + +func (ct *connTracker) Accept() (manet.Conn, error) { + conn, err := ct.Listener.Accept() + if err != nil { + return nil, err + } + parent := ct.parent + connDir, err := parent.getConnDir() + if err != nil { + return nil, unwind(err, conn.Close) + } + var ( + closeOnce, + unlinkOnce sync.Once + unlinked atomic.Bool + netErr error + tracked = p9net.NewTrackedConn(conn) + fileConn = &connCloser{ + trackedConn: tracked, + closeFn: func() error { + closeOnce.Do(func() { + unlinked.Store(true) + netErr = tracked.Close() + }) + return netErr + }, + } + index = parent.connIndex.Add(1) + name = strconv.Itoa(int(index)) + ) + const permissions = ReadOther | ReadGroup | ReadUser + _, file, err := connDir.newConnFile( + name, index, + permissions, p9.NoUID, p9.NoGID, + fileConn, + ) + if err != nil { + return nil, unwind(err, conn.Close, connDir.Close) + } + if err := connDir.Link(file, name); err != nil { + return nil, unwind(err, conn.Close, connDir.Close) + } + var ( + link = file.linkSync + connUnlinker = &connCloser{ + trackedConn: fileConn, + closeFn: func() error { + unlinkOnce.Do(func() { + if !unlinked.Load() { + unlinkChildSync(link) + } + }) + return fileConn.closeFn() + }, + } + ) + if err := connDir.Close(); err != nil { + return nil, unwind(err, conn.Close, fileConn.Close) + } + return connUnlinker, nil +} + +func (cf *connFile) marshal() ([]byte, error) { + tracked := cf.trackedConn + return json.Marshal(ConnInfo{ + ID: cf.connID, + Local: tracked.LocalMultiaddr(), + Remote: tracked.RemoteMultiaddr(), + LastRead: tracked.LastRead(), + LastWrite: tracked.LastWrite(), + }) +} + +func (cf *connFile) Walk(names []string) ([]p9.QID, p9.File, error) { + if len(names) > 0 { + return nil, nil, perrors.ENOTDIR + } + if cf.opened() { + return nil, nil, fidOpenedErr + } + return nil, &connFile{ + connID: cf.connID, + trackedConn: cf.trackedConn, + metadata: cf.metadata, + linkSync: cf.linkSync, + }, nil +} + +func (cf *connFile) SetAttr(valid p9.SetAttrMask, attr p9.SetAttr) error { + return cf.metadata.SetAttr(valid, attr) +} + +func (cf *connFile) GetAttr(req p9.AttrMask) (p9.QID, p9.AttrMask, p9.Attr, error) { + if req.Size { + data, err := cf.marshal() + if err != nil { + return p9.QID{}, p9.AttrMask{}, p9.Attr{}, err + } + cf.metadata.Size = uint64(len(data)) + } + return cf.metadata.GetAttr(req) +} + +func (cf *connFile) Open(mode p9.OpenFlags) (p9.QID, ioUnit, error) { + if cf.opened() { + return p9.QID{}, 0, perrors.EBADF + } + cf.openFlags = cf.withOpenedFlag(mode) + return cf.QID, 0, nil +} + +func (cf *connFile) Close() error { + cf.openFlags = 0 + cf.ReaderAt = nil + return nil +} + +func (cf *connFile) ReadAt(p []byte, offset int64) (int, error) { + reader := cf.ReaderAt + if reader == nil { + if !cf.canRead() { + return -1, perrors.EBADF + } + data, err := cf.marshal() + if err != nil { + return -1, err + } + reader = bytes.NewReader(data) + cf.ReaderAt = reader + } + return reader.ReadAt(p, offset) +} + +func (cc *connCloser) Close() error { return cc.closeFn() } + +func (ci *ConnInfo) UnmarshalJSON(data []byte) error { + var maddrBuff struct { + Local string `json:"local"` + Remote string `json:"remote"` + } + if err := json.Unmarshal(data, &maddrBuff); err != nil { + return err + } + var err error + if ci.Local, err = multiaddr.NewMultiaddr(maddrBuff.Local); err != nil { + return err + } + if ci.Remote, err = multiaddr.NewMultiaddr(maddrBuff.Remote); err != nil { + return err + } + return json.Unmarshal(data, &struct { + ID *uintptr `json:"#"` + LastRead *time.Time `json:"lastRead"` + LastWrite *time.Time `json:"lastWrite"` + }{ + ID: &ci.ID, + LastRead: &ci.LastRead, LastWrite: &ci.LastWrite, + }) +} diff --git a/internal/filesystem/9p/listener_test.go b/internal/filesystem/9p/listener_test.go new file mode 100644 index 00000000..27d4e17c --- /dev/null +++ b/internal/filesystem/9p/listener_test.go @@ -0,0 +1,281 @@ +package p9_test + +import ( + "bytes" + "context" + "errors" + "fmt" + "net" + "path" + "strings" + "testing" + + p9fs "github.com/djdv/go-filesystem-utils/internal/filesystem/9p" + "github.com/djdv/p9/p9" + "github.com/multiformats/go-multiaddr" + manet "github.com/multiformats/go-multiaddr/net" +) + +const listenerFileName = "listener" + +func TestListener(t *testing.T) { + t.Parallel() + t.Run("default", listenerDefault) + t.Run("options", listenerWithOptions) +} + +// best effort, not guaranteed to actually +// be a free port on all systems. +func getTCPPort(t *testing.T, address string) int { + const network = "tcp" + stdListener, err := net.Listen(network, address+":0") + if err != nil { + t.Fatalf("could not listen via std: %v", err) + } + port := stdListener.Addr().(*net.TCPAddr).Port + if err := stdListener.Close(); err != nil { + t.Fatalf("could not close std listener: %v", err) + } + return port +} + +func newTCPMaddr(t *testing.T, netIntf string) multiaddr.Multiaddr { + port := getTCPPort(t, netIntf) + return multiaddr.StringCast(fmt.Sprintf("/ip4/%s/tcp/%d", netIntf, port)) +} + +func listenerDefault(t *testing.T) { + t.Parallel() + const address = "127.0.0.1" + var ( + maddr = newTCPMaddr(t, address) + ctx, cancel = context.WithCancel(context.Background()) + ) + defer cancel() + _, listenerDir, listeners, lErr := p9fs.NewListener(ctx) + if lErr != nil { + t.Fatalf("could not create listener directory: %v", lErr) + } + listenerTCPServiceTest(t, listenerDir, listeners, maddr) + // Directories should still exist after listener closes + // since options were not specified. + names := maddrToNames(maddr) + mustWalkTo(t, listenerDir, names) +} + +func listenerWithOptions(t *testing.T) { + t.Parallel() + const ( + address = "127.0.0.1" + listenerBuffer = 1 + ) + var ( + maddr = newTCPMaddr(t, address) + ctx, cancel = context.WithCancel(context.Background()) + ) + defer cancel() + _, listenerDir, listeners, lErr := p9fs.NewListener(ctx, + p9fs.UnlinkEmptyChildren[p9fs.ListenerOption](true), + p9fs.WithBuffer[p9fs.ListenerOption](listenerBuffer), + ) + if lErr != nil { + t.Fatalf("could not create listener directory: %v", lErr) + } + + // This shouldn't hang because we requested a buffer. + const permissions = 0o751 + if err := p9fs.Listen(listenerDir, maddr, permissions); err != nil { + t.Fatalf("could not listen on %v: %v", maddr, err) + } + // We don't need to background this, again because of the buffer. + listener := <-listeners // Hold on to this while we test with another listener. + + maddr2 := newTCPMaddr(t, address) + listenerTCPServiceTest(t, listenerDir, listeners, maddr2) + + // Directories should still exist after other listeners + // close, since `listener` is still active. + names := maddrToNames(maddr) + mustWalkTo(t, listenerDir, names) + + // This should trigger a cleanup since no + // other listeners are using this chain of protocols. + if err := listener.Close(); err != nil { + t.Fatalf("could not close listener: %v", err) + } + + // Root should be empty after listener closes + // since cleanup options were provided and no other + // entry was added by this test. + ents, err := p9fs.ReadDir(listenerDir) + if err != nil { + t.Fatalf("could not read directory: %v", err) + } + if entCount := len(ents); entCount != 0 { + t.Fatalf("directory should be empty"+ + "\ngot: %v"+ + "\nwant: %v", + ents, nil, + ) + } +} + +func listenerTCPServiceTest(t *testing.T, listenerDir p9.File, listeners <-chan manet.Listener, maddr multiaddr.Multiaddr) { + var ( + errs = make(chan error) + payload = []byte("arbitrary data") + ) + go func() { + defer close(errs) + listener := <-listeners + if err := listenerMatches(listener, maddr); err != nil { + errs <- err + } + if err := listenerExists(listenerDir, maddr); err != nil { + errs <- err + } + if err := <-listenerHostEchoTCP(listener, payload); err != nil { + errs <- err + } + if err := listener.Close(); err != nil { + errs <- err + } + if err := listenerNotExist(listenerDir, maddr); err != nil { + errs <- err + } + }() + const permissions = 0o751 + if err := p9fs.Listen(listenerDir, maddr, permissions); err != nil { + t.Fatalf("could not listen on %v: %v", maddr, err) + } + listenerClientEchoTCP(t, maddr, payload) + var err error + for e := range errs { + err = errors.Join(e) + } + if err != nil { + t.Fatal(err) + } +} + +func listenerHostEchoTCP(listener manet.Listener, expected []byte) <-chan error { + errs := make(chan error, 1) + go func() { + var rErr error + defer func() { + if rErr != nil { + errs <- rErr + } + close(errs) + }() + conn, err := listener.Accept() + if err != nil { + rErr = fmt.Errorf("could not accept: %v", err) + return + } + data := make([]byte, len(expected)) + read, err := conn.Read(data) + if err != nil { + rErr = fmt.Errorf("could not read from connection: %v", err) + return + } + if err := conn.Close(); err != nil { + errs <- err + } + if want := len(expected); read != want { + rErr = fmt.Errorf("mismatched number of bytes read"+ + "\ngot: %d"+ + "\nwant: %d", + read, want, + ) + return + } + if !bytes.Equal(data, expected) { + rErr = fmt.Errorf("mismatched data read"+ + "\ngot: %v"+ + "\nwant: %v", + data, expected, + ) + return + } + }() + return errs +} + +func listenerClientEchoTCP(t *testing.T, maddr multiaddr.Multiaddr, payload []byte) { + conn, err := manet.Dial(maddr) + if err != nil { + t.Fatalf("could not dial: %v", err) + } + wrote, err := conn.Write(payload) + if err != nil { + t.Fatalf("could not write to connection: %v", err) + } + if err := conn.Close(); err != nil { + t.Error(err) + } + if want := len(payload); wrote != want { + t.Fatalf("mismatched number of bytes written"+ + "\ngot: %d"+ + "\nwant: %d", + wrote, len(payload), + ) + } +} + +func maddrToNames(maddr multiaddr.Multiaddr) []string { + return strings.Split(maddr.String(), "/")[1:] +} + +func listenerMatches(listener manet.Listener, maddr multiaddr.Multiaddr) error { + if lMaddr := listener.Multiaddr(); !maddr.Equal(lMaddr) { + return fmt.Errorf("mismatched listener address"+ + "\ngot: %v"+ + "\nwant: %v", + lMaddr, maddr, + ) + } + return nil +} + +func listenerExists(listenerDir p9.File, maddr multiaddr.Multiaddr) error { + listeners, err := p9fs.GetListeners(listenerDir) + if err != nil { + return err + } + for _, listener := range listeners { + if listener.Equal(maddr) { + return nil + } + } + return fmt.Errorf("listener file for \"%s\" should exist but was not found", maddr) +} + +func mustWalkTo(t *testing.T, file p9.File, names []string) { + t.Helper() + file, err := walkTo(file, names) + if err != nil { + t.Fatalf("could not walk to directory (%s): %v", path.Join(names...), err) + } + if err := file.Close(); err != nil { + t.Fatalf("could not close directory (%s): %v", path.Join(names...), err) + } +} + +func listenerNotExist(listenerDir p9.File, maddr multiaddr.Multiaddr) (err error) { + listeners, err := p9fs.GetListeners(listenerDir) + if err != nil { + return err + } + for _, listener := range listeners { + if listener.Equal(maddr) { + return fmt.Errorf("listener file for \"%s\" should not exist after listener is closed", maddr) + } + } + return nil +} + +func walkTo(root p9.File, names []string) (p9.File, error) { + _, file, err := root.Walk(names) + return file, err +} diff --git a/internal/filesystem/9p/mount.go b/internal/filesystem/9p/mount.go new file mode 100644 index 00000000..8399d2a1 --- /dev/null +++ b/internal/filesystem/9p/mount.go @@ -0,0 +1,330 @@ +package p9 + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "sync" + + "github.com/djdv/go-filesystem-utils/internal/filesystem" + "github.com/djdv/go-filesystem-utils/internal/generic" + perrors "github.com/djdv/p9/errors" + "github.com/djdv/p9/p9" +) + +type ( + MountFile struct { + directory + makeHostFn MakeHostFunc + } + // MakeHostFunc should handle file creation operations + // for files representing a [filesystem.Host]. + // The file `mode` will contain file type bits. + MakeHostFunc func(parent p9.File, host filesystem.Host, + mode p9.FileMode, + uid p9.UID, gid p9.GID) (p9.QID, p9.File, error) + mounterSettings struct { + directorySettings + } + MounterOption func(*mounterSettings) error + + unmountError struct { + error + target string + } + // DecodeTargetFunc will be called with bytes representing + // an encoded mount point, and should decode then return + // the mount point's target. + // Under typical operation, the encoded data should + // have the same format as the argument passed to + // [Client.Mount]. However, this is not guaranteed; + // as different clients with different formats may + // call `Mount` and `Unmount` independently. + DecodeTargetFunc func(filesystem.Host, filesystem.ID, []byte) (string, error) +) + +func (ue unmountError) Error() string { + return fmt.Sprintf( + "could not remove: \"%s\" - %s", + ue.target, ue.error, + ) +} + +func NewMounter(makeHostFn MakeHostFunc, options ...MounterOption) (p9.QID, *MountFile, error) { + var settings mounterSettings + settings.metadata.initialize(p9.ModeDirectory) + if err := generic.ApplyOptions(&settings, options...); err != nil { + return p9.QID{}, nil, err + } + qid, directory, err := newDirectory(&settings.directorySettings) + if err != nil { + return p9.QID{}, nil, err + } + return qid, &MountFile{ + directory: directory, + makeHostFn: makeHostFn, + }, nil +} + +func (mf *MountFile) Walk(names []string) ([]p9.QID, p9.File, error) { + qids, file, err := mf.directory.Walk(names) + if len(names) == 0 { + file = &MountFile{ + directory: file, + makeHostFn: mf.makeHostFn, + } + } + return qids, file, err +} + +func (mf *MountFile) Mkdir(name string, permissions p9.FileMode, uid p9.UID, gid p9.GID) (p9.QID, error) { + uid, gid, err := mkPreamble(mf, name, uid, gid) + if err != nil { + return p9.QID{}, err + } + mode := permissions | p9.ModeDirectory + qid, file, err := mf.makeHostFn(mf, filesystem.Host(name), + mode, uid, gid) + if err != nil { + return p9.QID{}, errors.Join(perrors.EACCES, err) + } + return qid, mf.Link(file, name) +} + +func (mf *MountFile) Create(name string, flags p9.OpenFlags, permissions p9.FileMode, + uid p9.UID, gid p9.GID, +) (p9.File, p9.QID, uint32, error) { + return createViaMknod(mf, name, flags, permissions, uid, gid) +} + +func (mf *MountFile) Mknod(name string, mode p9.FileMode, + _, _ uint32, uid p9.UID, gid p9.GID, +) (p9.QID, error) { + uid, gid, err := mkPreamble(mf, name, uid, gid) + if err != nil { + return p9.QID{}, err + } + mode |= p9.ModeRegular + qid, file, err := mf.makeHostFn(mf, filesystem.Host(name), + mode, uid, gid) + if err != nil { + return p9.QID{}, errors.Join(perrors.EACCES, err) + } + return qid, mf.Link(file, name) +} + +func UnmountAll(mounts p9.File) error { + return UnmountTargets(mounts, nil, nil) +} + +func UnmountTargets(mounts p9.File, + mountPoints []string, decodeTargetFn DecodeTargetFunc, +) error { + var ( + errs []error + unlinked = make([]string, 0, len(mountPoints)) + ctx, cancel = context.WithCancel(context.Background()) + results = unmountTargets(ctx, mounts, + mountPoints, decodeTargetFn) + ) + defer cancel() + for result := range results { + if err := result.error; err != nil { + errs = append(errs, err) + continue + } + unlinked = append(unlinked, result.value) + } + if len(mountPoints) != len(unlinked) || + errs != nil { + return formatUnmountErr(mountPoints, unlinked, errs) + } + return nil +} + +func unmountTargets(ctx context.Context, + mounts p9.File, mountPoints []string, + decodeTargetFn DecodeTargetFunc, +) <-chan stringResult { + return mapDirPipeline(ctx, mounts, + func(ctx context.Context, dir p9.File, + wg *sync.WaitGroup, results chan<- stringResult, + ) { + unmountTargetsPipeline(ctx, dir, + mountPoints, decodeTargetFn, + wg, results, + ) + }) +} + +func unmountTargetsPipeline(ctx context.Context, + mounts p9.File, mountPoints []string, decodeTargetFn DecodeTargetFunc, + wg *sync.WaitGroup, results chan<- stringResult, +) { + defer wg.Done() + unmountAll := mountPoints == nil + checkErr := func(err error) (sawError bool) { + if sawError = err != nil; sawError { + sendResult(ctx, results, stringResult{error: err}) + } + return sawError + } + processEntry := func(result direntResult, dir p9.File, dirWg *sync.WaitGroup) { + defer dirWg.Done() + if checkErr(result.error) { + return + } + entry := result.value + const unlinkFlags = 0 + if unmountAll { + checkErr(dir.UnlinkAt(entry.Name, unlinkFlags)) + return + } + unmountGuestEntry(ctx, + dir, entry, + mountPoints, decodeTargetFn, + results, + ) + } + processGuest := func(result fileResult) { + defer wg.Done() + if checkErr(result.error) { + return + } + var ( + dirWg sync.WaitGroup + guestDir = result.value + guestResults = getDirents(ctx, guestDir) + ) + for result := range guestResults { + dirWg.Add(1) + go processEntry(result, guestDir, &dirWg) + } + wg.Add(1) + go func() { + defer wg.Done() + dirWg.Wait() + checkErr(guestDir.Close()) + }() + } + for result := range flattenMounts(ctx, mounts) { + wg.Add(1) + go processGuest(result) + } +} + +// flattenMounts returns all guest directories +// for all hosts within mounts. +func flattenMounts(ctx context.Context, mounts p9.File) <-chan fileResult { + return mapDirPipeline(ctx, mounts, flattenMountsPipeline) +} + +func flattenMountsPipeline(ctx context.Context, mounts p9.File, + wg *sync.WaitGroup, results chan<- fileResult, +) { + defer wg.Done() + processHost := func(result fileResult) { + defer wg.Done() + if err := result.error; err != nil { + sendResult(ctx, results, fileResult{error: err}) + return + } + var ( + hostDir = result.value + hostResults = getDirFiles(ctx, hostDir) + ) + if err := hostDir.Close(); err != nil { + sendResult(ctx, results, fileResult{error: err}) + } + for result := range hostResults { + wg.Add(1) + go func(res fileResult) { + defer wg.Done() + sendResult(ctx, results, res) + }(result) + } + } + for result := range getDirFiles(ctx, mounts) { + wg.Add(1) + go processHost(result) + } +} + +func unmountGuestEntry(ctx context.Context, + dir p9.File, entry p9.Dirent, + mountPoints []string, decodeTargetFn DecodeTargetFunc, + results chan<- stringResult, +) { + mountFile, err := walkEnt(dir, entry) + if err != nil { + sendResult(ctx, results, stringResult{error: err}) + return + } + defer func() { + if err := mountFile.Close(); err != nil { + sendResult(ctx, results, stringResult{error: err}) + } + }() + target, err := parseMountFile(mountFile, decodeTargetFn) + if err != nil { + sendResult(ctx, results, stringResult{error: err}) + return + } + for _, point := range mountPoints { + if point != target { + continue + } + const unlinkFlags = 0 + err := dir.UnlinkAt(entry.Name, unlinkFlags) + if err != nil { + err = unmountError{target: target, error: err} + } + sendResult(ctx, results, stringResult{value: target, error: err}) + return + } +} + +func parseMountFile(file p9.File, decodeFn DecodeTargetFunc) (string, error) { + fileData, err := ReadAll(file) + if err != nil { + return "", err + } + var point mountPointMarshal + if err := json.Unmarshal(fileData, &point); err != nil { + return "", err + } + return decodeFn(point.Host, point.ID, point.Data) +} + +func formatUnmountErr(mountPoints, unlinked []string, errs []error) error { + faulty := make([]string, 0, len(errs)) + for _, err := range errs { + var uErr unmountError + if errors.As(err, &uErr) { + faulty = append(faulty, uErr.target) + } + } + var ( + skip = append(faulty, unlinked...) + remaining = make([]string, 0, len(mountPoints)-len(skip)) + ) +reduce: + for _, target := range mountPoints { + for _, skipped := range skip { + if target == skipped { + continue reduce + } + } + remaining = append(remaining, fmt.Sprintf(`"%s"`, target)) + } + const prefix = "could not find mount point" + var errStr string + if len(remaining) == 1 { + errStr = fmt.Sprintf(prefix+": %s", remaining[0]) + } else { + errStr = fmt.Sprintf(prefix+"s: %s", strings.Join(remaining, ", ")) + } + return errors.Join(append(errs, errors.New(errStr))...) +} diff --git a/internal/filesystem/9p/mountpoint.go b/internal/filesystem/9p/mountpoint.go new file mode 100644 index 00000000..4344bcfa --- /dev/null +++ b/internal/filesystem/9p/mountpoint.go @@ -0,0 +1,415 @@ +package p9 + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "strings" + "sync" + + "github.com/djdv/go-filesystem-utils/internal/filesystem" + "github.com/djdv/go-filesystem-utils/internal/generic" + perrors "github.com/djdv/p9/errors" + "github.com/djdv/p9/fsimpl/templatefs" + "github.com/djdv/p9/p9" +) + +type ( + // FieldParser should parse and assign its inputs. + // Returning either a [FieldError] if the key is not applicable, + // or any other error if the value is invalid. + FieldParser interface { + ParseField(key, value string) error + } + // FieldError describes which key was searched for + // and the available fields which were tried. + // Useful for chaining [FieldParser.ParseField] calls with [errors.As]. + FieldError struct { + Key string + Tried []string + } + SystemMaker interface { + MakeFS() (fs.FS, error) + } + Mounter interface { + Mount(fs.FS) (io.Closer, error) + } + mountPointTag struct { + filesystem.Host `json:"host"` + filesystem.ID `json:"guest"` + } + mountPointMarshal struct { + mountPointTag `json:"tag"` + Data json.RawMessage `json:"data"` + } + HostIdentifier interface { + HostID() filesystem.Host + } + GuestIdentifier interface { + GuestID() filesystem.ID + } + MountPoint interface { + SystemMaker + Mounter + HostIdentifier + GuestIdentifier + } + MountPointFile[MP MountPoint] struct { + mountPointFile + mountPoint MP + mountPointHost + mountPointIO + } + mountPointFile struct { + templatefs.NoopFile + *metadata + mu *sync.Mutex + linkSync *linkSync + } + mountPointIO struct { + reader *bytes.Reader + buffer *bytes.Buffer + openFlags + fieldMode bool + modified bool + } + detachFunc = func() error + mountPointHost struct { + unmountFn *detachFunc + } + MountPointOption func(*fileSettings) error +) + +func (fe FieldError) Error() string { + // Format: + // unexpected key: "${key}", want one of: $QuotedCSV(${tried}) + const ( + delimiter = ',' + space = ' ' + separator = string(delimiter) + string(space) + separated = len(separator) + surrounder = '"' + surrounded = len(string(surrounder)) * 2 + padding = surrounded + separated + gotPrefix = "unexpected key: " + wantPrefix = "want one of: " + prefixes = len(gotPrefix) + surrounded + + len(wantPrefix) + separated + ) + var ( + b strings.Builder + key = fe.Key + size = prefixes + len(key) + ) + for i, tried := range fe.Tried { + size += len(tried) + surrounded + if i != 0 { + size += separated + } + } + b.Grow(size) + b.WriteString(gotPrefix) + b.WriteRune(surrounder) + b.WriteString(key) + b.WriteRune(surrounder) + b.WriteString(separator) + b.WriteString(wantPrefix) + end := len(fe.Tried) - 1 + for i, tried := range fe.Tried { + b.WriteRune(surrounder) + b.WriteString(tried) + b.WriteRune(surrounder) + if i != end { + b.WriteString(separator) + } + } + return b.String() +} + +func NewMountPoint[ + MP interface { + *T + MountPoint + }, + T any, +](options ...MountPointOption, +) (p9.QID, *MountPointFile[MP], error) { + var settings fileSettings + settings.metadata.initialize(p9.ModeRegular) + if err := generic.ApplyOptions(&settings, options...); err != nil { + return p9.QID{}, nil, err + } + file := &MountPointFile[MP]{ + mountPoint: new(T), + mountPointFile: mountPointFile{ + metadata: &settings.metadata, + linkSync: &settings.linkSync, + mu: new(sync.Mutex), + }, + mountPointHost: mountPointHost{ + unmountFn: new(detachFunc), + }, + } + settings.metadata.fillDefaults() + settings.metadata.incrementPath() + return settings.QID, file, nil +} + +func (mf *MountPointFile[MP]) SetAttr(valid p9.SetAttrMask, attr p9.SetAttr) error { + return mf.metadata.SetAttr(valid, attr) +} + +func (mf *MountPointFile[MP]) GetAttr(req p9.AttrMask) (p9.QID, p9.AttrMask, p9.Attr, error) { + return mf.metadata.GetAttr(req) +} + +func (mf *MountPointFile[MP]) Walk(names []string) ([]p9.QID, p9.File, error) { + if len(names) > 0 { + return nil, nil, perrors.ENOTDIR + } + if mf.opened() { + return nil, nil, fidOpenedErr + } + return nil, &MountPointFile[MP]{ + mountPointFile: mf.mountPointFile, + mountPointHost: mountPointHost{ + unmountFn: mf.unmountFn, + }, + mountPoint: mf.mountPoint, + }, nil +} + +func (mf *MountPointFile[MP]) Open(mode p9.OpenFlags) (p9.QID, ioUnit, error) { + mf.mu.Lock() + defer mf.mu.Unlock() + if mf.opened() { + return p9.QID{}, noIOUnit, perrors.EBADF + } + mf.openFlags = mf.withOpenedFlag(mode) + return mf.QID, noIOUnit, nil +} + +func (mf *MountPointFile[MP]) WriteAt(p []byte, offset int64) (int, error) { + mf.mu.Lock() + defer mf.mu.Unlock() + if !mf.canWrite() { + return -1, perrors.EBADF + } + if len(p) == 0 { + return 0, nil + } + if offset == 0 { // Retain same mode on subsequent writes. + mf.fieldMode = p[0] != '{' + } + var ( + written int + err error + ) + if mf.fieldMode { + written = len(p) + err = mf.parseFieldsLocked(p) + } else { + written, err = mf.bufferStructuredLocked(p, offset) + } + if err != nil { + return -1, err + } + return written, err +} + +func (mf *MountPointFile[MP]) parseFieldsLocked(b []byte) error { + const ( + key = 0 + value = 1 + ) + for _, fields := range tokenize(b) { + switch fields.typ() { + case keyAndValue: + parser, ok := any(mf.mountPoint).(FieldParser) + if !ok { + // TODO: [Go 1.21] use [errors.ErrUnsupported]. + const unsupported = generic.ConstError("unsupported operation") + return fmt.Errorf( + "%w - %w: %T does not implement field parser", + perrors.EINVAL, unsupported, mf.mountPoint, + ) + } + key, value := fields[key], fields[value] + if err := parser.ParseField(key, value); err != nil { + return errors.Join(perrors.EINVAL, err) + } + mf.modified = true + case keyWord: + key := fields[key] + if err := mf.parseKeyWordLocked(key); err != nil { + return errors.Join(perrors.EINVAL, err) + } + default: + // TODO: insert input into message? probably. + return fmt.Errorf("%w - unexpected input", perrors.EINVAL) + } + } + return nil +} + +func (mf *MountPointFile[MP]) serializeLocked() ([]byte, error) { + mb, err := json.Marshal(mf.mountPoint) + if err != nil { + return nil, err + } + return json.Marshal(mountPointMarshal{ + Data: json.RawMessage(mb), + mountPointTag: mountPointTag{ + Host: mf.mountPoint.HostID(), + ID: mf.mountPoint.GuestID(), + }, + }) +} + +func (mf *MountPointFile[MP]) parseKeyWordLocked(keyWord string) error { + const syncKey = "sync" + if keyWord == syncKey { + return mf.syncLocked() + } + return FieldError{ + Key: keyWord, + Tried: []string{syncKey}, + } + // TODO: Expected one of: $... + // return fmt.Errorf("%w - invalid keyword: %s", perrors.EINVAL, keyWord) +} + +func (mf *MountPointFile[MP]) bufferStructuredLocked(p []byte, offset int64) (int, error) { + buffer := mf.buffer + if buffer == nil { + buffer = new(bytes.Buffer) + mf.buffer = buffer + } + if dLen := buffer.Len(); offset != int64(dLen) { + err := fmt.Errorf( + "%w - structured input must append only", + perrors.EINVAL, + ) + return -1, err + } + mf.modified = true + return buffer.Write(p) +} + +func (mf *MountPointFile[MP]) FSync() error { + mf.mu.Lock() + defer mf.mu.Unlock() + return mf.syncLocked() +} + +func (mf *MountPointFile[MP]) syncLocked() error { + if !mf.modified { + return nil + } + if err := mf.flushBufferLocked(); err != nil { + return err + } + mf.modified = false + data, err := mf.serializeLocked() + if err != nil { + return err + } + mf.Size = uint64(len(data)) + if err := mf.resetReaderLocked(data); err != nil { + return err + } + return mf.remountLocked() +} + +func (mf *MountPointFile[MP]) resetReaderLocked(data []byte) error { + reader := mf.reader + if reader == nil { + return nil + } + offset, err := reader.Seek(0, io.SeekCurrent) + if err != nil { + return err + } + reader.Reset(data) + _, err = reader.Seek(offset, io.SeekStart) + return err +} + +func (mf *MountPointFile[MP]) flushBufferLocked() error { + buffer := mf.buffer + if buffer == nil || + buffer.Len() == 0 { + return nil + } + defer buffer.Reset() + data := buffer.Bytes() + return json.Unmarshal(data, &mf.mountPoint) +} + +func (mf *MountPointFile[MP]) remountLocked() error { + if unmount := *mf.unmountFn; unmount != nil { + if err := unmount(); err != nil { + return err + } + } + return mf.mountFileLocked() +} + +func (mf *MountPointFile[MP]) mountFileLocked() error { + goFS, err := mf.mountPoint.MakeFS() + if err != nil { + return err + } + closer, err := mf.mountPoint.Mount(goFS) + if err == nil { + *mf.unmountFn = closer.Close + return nil + } + if parent := mf.linkSync.parent; parent != nil { + const flags = 0 + child := mf.linkSync.child + return errors.Join( + perrors.EIO, + err, + parent.UnlinkAt(child, flags), + ) + } + return errors.Join(perrors.EIO, err) +} + +func (mf *MountPointFile[MP]) ReadAt(p []byte, offset int64) (int, error) { + mf.mu.Lock() + defer mf.mu.Unlock() + reader := mf.reader + if reader == nil { + if !mf.canRead() { + return -1, perrors.EBADF + } + data, err := mf.serializeLocked() + if err != nil { + // TODO: check spec for best errno + return -1, errors.Join(perrors.EIO, err) + } + reader = bytes.NewReader(data) + mf.reader = reader + } + return reader.ReadAt(p, offset) +} + +func (mf *MountPointFile[MP]) Close() error { + err := mf.FSync() + mf.openFlags = 0 + mf.reader = nil + mf.buffer = nil + return err +} + +func (mf *MountPointFile[MP]) detach() error { + if detach := *mf.unmountFn; detach != nil { + return detach() + } + return nil +} diff --git a/internal/filesystem/9p/operations.go b/internal/filesystem/9p/operations.go new file mode 100644 index 00000000..4139e268 --- /dev/null +++ b/internal/filesystem/9p/operations.go @@ -0,0 +1,547 @@ +package p9 + +import ( + "context" + "errors" + "fmt" + "io" + "math" + "strings" + "sync" + + perrors "github.com/djdv/p9/errors" + "github.com/djdv/p9/p9" + "github.com/multiformats/go-multiaddr" +) + +type ( + chanEmitter[T any] struct { + context.Context + ch chan T + sync.Mutex + } + result[T any] struct { + error + value T + } + direntResult = result[p9.Dirent] + fileResult = result[p9.File] + maddrResult = result[multiaddr.Multiaddr] + connInfoResult = result[ConnInfo] + stringResult = result[string] + + // dataField must be of length 1 with just a key name, + // or of length 2 with a key and value. + dataField []string + dataTokens []dataField + fieldType uint + + openFlags p9.OpenFlags +) + +const ( + fileOpened = openFlags(p9.OpenFlagsModeMask + 1) + + keyWord fieldType = 1 + keyAndValue fieldType = 2 + + // NOTE: [2023.01.02] + // The reference documentation and implementation + // do not specify which error number to use. + // If this value seems incorrect, request to change it. + fidOpenedErr = perrors.EBUSY +) + +func (df dataField) typ() fieldType { return fieldType(len(df)) } + +func (of openFlags) withOpenedFlag(mode p9.OpenFlags) openFlags { + return openFlags(mode.Mode()) | fileOpened +} + +func (of openFlags) opened() bool { + return of&fileOpened != 0 +} + +func (of openFlags) Mode() p9.OpenFlags { + return p9.OpenFlags(of).Mode() +} + +func (of openFlags) canRead() bool { + return of.opened() && + (of.Mode() == p9.ReadOnly || of.Mode() == p9.ReadWrite) +} + +func (of openFlags) canWrite() bool { + return of.opened() && + (of.Mode() == p9.WriteOnly || of.Mode() == p9.ReadWrite) +} + +func sendSingle[T any](value T) <-chan T { + buffer := make(chan T, 1) + buffer <- value + close(buffer) + return buffer +} + +func sendResult[T any, R result[T]](ctx context.Context, results chan<- R, res R) bool { + select { + case results <- res: + return true + case <-ctx.Done(): + return false + } +} + +func makeChannelEmitter[T any](ctx context.Context, buffer int) *chanEmitter[T] { + var ( + ch = make(chan T, buffer) + emitter = &chanEmitter[T]{ + Context: ctx, + ch: ch, + } + ) + emitter.closeWhenDone() + return emitter +} + +func (ce *chanEmitter[T]) closeWhenDone() { + var ( + ctx = ce.Context + mu = &ce.Mutex + ch = ce.ch + ) + go func() { + <-ctx.Done() + mu.Lock() + defer mu.Unlock() + close(ch) + ce.ch = nil // See: [emit]. + }() +} + +func (ce *chanEmitter[T]) emit(value T) error { + ce.Mutex.Lock() + defer ce.Mutex.Unlock() + var ( + ctx = ce.Context + ch = ce.ch + ) + if ch == nil { // See: [closeWhenDone]. + return ctx.Err() + } + select { + case ch <- value: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func createViaMknod(fsys p9.File, name string, flags p9.OpenFlags, + permissions p9.FileMode, uid p9.UID, gid p9.GID, +) (p9.File, p9.QID, ioUnit, error) { + if qid, err := fsys.Mknod(name, permissions, 0, 0, uid, gid); err != nil { + return nil, qid, 0, err + } + const ulFlags = 0 + _, file, err := fsys.Walk([]string{name}) + if err != nil { + return nil, p9.QID{}, 0, errors.Join(err, fsys.UnlinkAt(name, ulFlags)) + } + qid, ioUnit, err := file.Open(flags) + if err != nil { + return nil, p9.QID{}, 0, errors.Join(err, fsys.UnlinkAt(name, ulFlags)) + } + return file, qid, ioUnit, nil +} + +func MkdirAll(root p9.File, names []string, + permissions p9.FileMode, uid p9.UID, gid p9.GID, +) (p9.File, error) { + _, current, err := root.Walk(nil) + if err != nil { + return nil, err + } + for _, name := range names { + var ( + next, err = mkdirAndWalk(current, name, permissions, uid, gid) + cErr = current.Close() + ) + if err != nil { + return nil, errors.Join(err, cErr) + } + if cErr != nil { + return nil, errors.Join(cErr, next.Close()) + } + current = next + } + return current, nil +} + +func mkdirAndWalk(fsys p9.File, name string, permissions p9.FileMode, uid p9.UID, gid p9.GID) (p9.File, error) { + wnames := []string{name} + if _, err := fsys.Mkdir(name, permissions, uid, gid); err != nil && + !errors.Is(err, perrors.EEXIST) { + return nil, err + } + _, dir, err := fsys.Walk(wnames) + return dir, err +} + +// mkPreamble makes sure name does not exist +// and may substitute ID values. +// Intended to be called at the beginning of +// mk* functions {mkdir, mknod, etc.}. +func mkPreamble(parent p9.File, name string, + uid p9.UID, gid p9.GID, +) (p9.UID, p9.GID, error) { + exists, err := childExists(parent, name) + if err != nil { + return p9.NoUID, p9.NoGID, err + } + if exists { + return p9.NoUID, p9.NoGID, perrors.EEXIST + } + return maybeInheritIDs(parent, uid, gid) +} + +func ReadDir(dir p9.File) (p9.Dirents, error) { + var ( + ents p9.Dirents + ctx, cancel = context.WithCancel(context.Background()) + ) + defer cancel() + for result := range getDirents(ctx, dir) { + if err := result.error; err != nil { + return nil, err + } + ents = append(ents, result.value) + } + return ents, nil +} + +func getDirents(ctx context.Context, dir p9.File) <-chan direntResult { + return mapDirPipeline(ctx, dir, getDirentsPipeline) +} + +func getDirentsPipeline(ctx context.Context, dir p9.File, wg *sync.WaitGroup, results chan<- direntResult) { + defer wg.Done() + if _, _, err := dir.Open(p9.ReadOnly); err != nil { + sendResult(ctx, results, direntResult{error: err}) + return + } + var offset uint64 + for { + entires, err := dir.Readdir(offset, math.MaxUint32) + if err != nil { + sendResult(ctx, results, direntResult{error: err}) + return + } + entryCount := len(entires) + if entryCount == 0 { + return + } + for _, entry := range entires { + if !sendResult(ctx, results, direntResult{value: entry}) { + return + } + } + offset = entires[entryCount-1].Offset + } +} + +// getDirFiles retrieves all files within the directory (1 layer deep). +// It is the callers responsibility to close the returned files when done. +func getDirFiles(ctx context.Context, dir p9.File) <-chan fileResult { + return mapDirPipeline(ctx, dir, getDirFilesPipeline) +} + +func getDirFilesPipeline(ctx context.Context, dir p9.File, wg *sync.WaitGroup, results chan<- fileResult) { + defer wg.Done() + processEntry := func(result direntResult) { + defer wg.Done() + if err := result.error; err != nil { + sendResult(ctx, results, fileResult{error: err}) + return + } + var ( + entry = result.value + file, err = walkEnt(dir, entry) + ) + if !sendResult(ctx, results, fileResult{value: file, error: err}) { + if file != nil { + file.Close() // Ignore the error (no receivers). + } + } + } + for result := range getDirents(ctx, dir) { + wg.Add(1) + go processEntry(result) + } +} + +func walkEnt(parent p9.File, ent p9.Dirent) (p9.File, error) { + wnames := []string{ent.Name} + _, child, err := parent.Walk(wnames) + return child, err +} + +// ReadAll performs the following sequence on file: +// clone, stat(size), open(read-only), read, close. +func ReadAll(file p9.File) ([]byte, error) { + // TODO: walkgetattr with fallback. + _, fileClone, err := file.Walk(nil) + if err != nil { + return nil, err + } + + want := p9.AttrMask{Size: true} + _, valid, attr, err := fileClone.GetAttr(want) + if err != nil { + return nil, errors.Join(err, fileClone.Close()) + } + if !valid.Contains(want) { + return nil, errors.Join( + attrErr(valid, want), + fileClone.Close(), + ) + } + + if _, _, err := fileClone.Open(p9.ReadOnly); err != nil { + return nil, errors.Join(err, fileClone.Close()) + } + sr := io.NewSectionReader(fileClone, 0, int64(attr.Size)) + data, err := io.ReadAll(sr) + return data, errors.Join(err, fileClone.Close()) +} + +func renameAt(oldDir, newDir p9.File, oldName, newName string) error { + _, file, err := oldDir.Walk([]string{oldName}) + if err != nil { + return err + } + err = rename(file, oldDir, newDir, oldName, newName) + if cErr := file.Close(); cErr != nil { + const closeFmt = "could not close old file: %w" + return errors.Join(err, fmt.Errorf(closeFmt, cErr)) + } + return err +} + +func rename(file, oldDir, newDir p9.File, oldName, newName string) error { + if err := newDir.Link(file, newName); err != nil { + return err + } + const flags uint32 = 0 + err := oldDir.UnlinkAt(oldName, flags) + if err != nil { + if uErr := newDir.UnlinkAt(newName, flags); uErr != nil { + const unlinkFmt = "could not unlink new file: %w" + return errors.Join(err, fmt.Errorf(unlinkFmt, uErr)) + } + return err + } + return nil +} + +// flattenDir returns all files within a directory (recursively). +// It is the callers responsibility to close the returned files when done. +func flattenDir(ctx context.Context, dir p9.File) <-chan fileResult { + return mapDirPipeline(ctx, dir, flattenPipeline) +} + +func flattenPipeline(ctx context.Context, dir p9.File, + wg *sync.WaitGroup, results chan<- fileResult, +) { + defer wg.Done() + processEntry := func(result direntResult) { + defer wg.Done() + if err := result.error; err != nil { + sendResult(ctx, results, fileResult{error: err}) + return + } + var ( + entry = result.value + file, err = walkEnt(dir, entry) + ) + if entry.Type == p9.TypeDir { + const recurAndClose = 2 + wg.Add(recurAndClose) + go func() { + defer wg.Done() + flattenPipeline(ctx, file, wg, results) + if err := file.Close(); err != nil { + sendResult(ctx, results, fileResult{error: err}) + } + }() + return + } + if !sendResult(ctx, results, fileResult{value: file, error: err}) { + if file != nil { + file.Close() // Ignore the error (no receivers). + } + } + } + for entryResult := range getDirents(ctx, dir) { + wg.Add(1) + go processEntry(entryResult) + } +} + +func findFiles(ctx context.Context, root p9.File, name string) <-chan fileResult { + return mapDirPipeline(ctx, root, func(ctx context.Context, dir p9.File, + wg *sync.WaitGroup, results chan<- fileResult, + ) { + findFilesPipeline(ctx, dir, name, wg, results) + }) +} + +// findFilesPipeline recursively searches the `root` +// for any files named `name`. +func findFilesPipeline(ctx context.Context, root p9.File, name string, wg *sync.WaitGroup, results chan<- fileResult) { + defer wg.Done() + processEntry := func(result direntResult) { + defer wg.Done() + if err := result.error; err != nil { + sendResult(ctx, results, fileResult{error: err}) + return + } + entry := result.value + if entry.Name == name { + file, err := walkEnt(root, entry) + if !sendResult(ctx, results, fileResult{value: file, error: err}) { + if file != nil { + file.Close() // Ignore the error (no receivers). + } + return + } + } + if entry.Type != p9.TypeDir { + return + } + dir, err := walkEnt(root, entry) + if err != nil { + sendResult(ctx, results, fileResult{error: err}) + return + } + wg.Add(1) + go func() { + defer wg.Done() + var recurWg sync.WaitGroup + recurWg.Add(1) + findFilesPipeline(ctx, dir, name, &recurWg, results) + recurWg.Wait() + if err := dir.Close(); err != nil { + sendResult(ctx, results, fileResult{error: err}) + } + }() + } + for entryResult := range getDirents(ctx, root) { + wg.Add(1) + go processEntry(entryResult) + } +} + +// tokenize is for parsing data from Read/Write. +// Returning a list of tokens +// which contain a list of fields. +func tokenize(p []byte) dataTokens { + var ( + str = string(p) + split = strings.FieldsFunc(str, func(r rune) bool { + return r == '\t' || r == '\r' || r == '\n' + }) + tokens = make(dataTokens, len(split)) + ) + for i, token := range split { + fields := strings.Fields(token) + if len(fields) > 2 { // Preserve spaces in values. + fields = dataField{ + fields[0], + strings.Join(fields[1:], " "), + } + } + tokens[i] = fields + } + return tokens +} + +func unlinkChildSync(link *linkSync) error { + link.mu.Lock() + defer link.mu.Unlock() + _, clone, err := link.parent.Walk(nil) + if err != nil { + return err + } + const flags = 0 + return errors.Join( + clone.UnlinkAt(link.child, flags), + clone.Close(), + ) +} + +func aggregateResults[T any, R result[T]](cancel context.CancelFunc, results <-chan R) ([]T, error) { + // Conversion necessary until + // golang/go #48522 is resolved. + type rc = result[T] + var ( + values = make([]T, 0, cap(results)) + errs []error + ) + for result := range results { + if err := rc(result).error; err != nil { + cancel() + errs = append(errs, err) + continue + } + values = append(values, rc(result).value) + } + if errs != nil { + return nil, errors.Join(errs...) + } + return values, nil +} + +func mapDirPipeline[ + T any, + P func(context.Context, p9.File, *sync.WaitGroup, chan<- result[T]), +](ctx context.Context, + dir p9.File, + pipeline P, +) <-chan result[T] { + // TODO: In Go 1.21 this can go into the type parameters list. + // Go 1.20.4 does not see it as a matching type (despite + // the alias working all the same). + type R = result[T] + _, clone, err := dir.Walk(nil) + if err != nil { + return sendSingle(R{error: err}) + } + var ( + wg sync.WaitGroup + results = make(chan R) + ) + wg.Add(1) + go pipeline(ctx, clone, &wg, results) + go func() { + wg.Wait() + if err := clone.Close(); err != nil { + sendResult(ctx, results, R{error: err}) + } + close(results) + }() + return results +} + +func unwind(err error, funcs ...func() error) error { + var errs []error + for _, fn := range funcs { + if fnErr := fn(); fnErr != nil { + errs = append(errs, fnErr) + } + } + if errs == nil { + return err + } + return errors.Join(append([]error{err}, errs...)...) +} diff --git a/internal/filesystem/9p/stat.go b/internal/filesystem/9p/stat.go new file mode 100644 index 00000000..1d33651e --- /dev/null +++ b/internal/filesystem/9p/stat.go @@ -0,0 +1,232 @@ +package p9 + +import ( + "fmt" + "sync/atomic" + "time" + + "github.com/djdv/go-filesystem-utils/internal/generic" + "github.com/djdv/p9/p9" +) + +// Permission mode bits. +const ( + ExecuteOther p9.FileMode = p9.Exec << iota + WriteOther + ReadOther + + ExecuteGroup + WriteGroup + ReadGroup + + ExecuteUser + WriteUser + ReadUser +) + +const noIOUnit ioUnit = 0 + +type ( + ioUnit = uint32 + ninePath = *atomic.Uint64 + metadata struct { + ninePath + p9.Attr + p9.QID + } + fileSettings struct { + linkSync + metadata + } + metadataSetter[T any] interface { + *T + setPath(ninePath) + setPermissions(p9.FileMode) + setUID(p9.UID) + setGID(p9.GID) + } +) + +func (md *metadata) setPath(path ninePath) { md.ninePath = path } +func (md *metadata) setUID(uid p9.UID) { md.UID = uid } +func (md *metadata) setGID(gid p9.GID) { md.GID = gid } +func (md *metadata) setPermissions(permissions p9.FileMode) { + md.Mode = md.Mode.FileType() | + permissions.Permissions() +} + +func (md *metadata) initialize(mode p9.FileMode) { + var ( + now = time.Now() + sec, nano = uint64(now.Unix()), uint64(now.UnixNano()) + ) + md.Attr = p9.Attr{ + Mode: mode, + UID: p9.NoUID, GID: p9.NoGID, + ATimeSeconds: sec, ATimeNanoSeconds: nano, + MTimeSeconds: sec, MTimeNanoSeconds: nano, + CTimeSeconds: sec, CTimeNanoSeconds: nano, + } + md.QID = p9.QID{ + Type: mode.QIDType(), + } +} + +func (md *metadata) fillDefaults() { + if md.ninePath == nil { + md.ninePath = new(atomic.Uint64) + } +} + +// WithPath specifies the path +// to be used by this file. +func WithPath[ + OT generic.OptionFunc[T], + T any, + I metadataSetter[T], +](path *atomic.Uint64, +) OT { + return func(status *T) error { + if path == nil { + return generic.ConstError("path option's value is `nil`") + } + any(status).(I).setPath(path) + return nil + } +} + +// WithPermissions specifies the permission bits +// for a file's mode status. +func WithPermissions[ + OT generic.OptionFunc[T], + T any, + I metadataSetter[T], +](permissions p9.FileMode, +) OT { + return func(status *T) error { + any(status).(I).setPermissions(permissions) + return nil + } +} + +// WithUID specifies a UID value for +// a file's status information. +func WithUID[ + OT generic.OptionFunc[T], + T any, + I metadataSetter[T], +](uid p9.UID, +) OT { + return func(status *T) error { + any(status).(I).setUID(uid) + return nil + } +} + +// WithGID specifies a GID value for +// a file's status information. +func WithGID[ + OT generic.OptionFunc[T], + T any, + I metadataSetter[T], +](gid p9.GID, +) OT { + return func(status *T) error { + any(status).(I).setGID(gid) + return nil + } +} + +func (md *metadata) incrementPath() { + md.QID.Path = md.ninePath.Add(1) +} + +func (md *metadata) SetAttr(valid p9.SetAttrMask, attr p9.SetAttr) error { + var ( + ourAttr = md.Attr + ourAtime = !valid.ATimeNotSystemTime + ourMtime = !valid.MTimeNotSystemTime + cTime = valid.CTime + ) + if usingClock := ourAtime || ourMtime || cTime; usingClock { + var ( + now = time.Now() + sec = uint64(now.Unix()) + nano = uint64(now.UnixNano()) + ) + if ourAtime { + valid.ATime = false + ourAttr.ATimeSeconds, ourAttr.ATimeNanoSeconds = sec, nano + } + if ourMtime { + valid.MTime = false + ourAttr.MTimeSeconds, ourAttr.MTimeNanoSeconds = sec, nano + } + if cTime { + ourAttr.CTimeSeconds, ourAttr.CTimeNanoSeconds = sec, nano + } + } + ourAttr.Apply(valid, attr) + return nil +} + +func (md *metadata) GetAttr(req p9.AttrMask) (p9.QID, p9.AttrMask, p9.Attr, error) { + validAttrs(&req, &md.Attr) + if req.INo { + req.INo = md.ninePath != nil + } + return md.QID, req, md.Attr, nil +} + +func validAttrs(req *p9.AttrMask, attr *p9.Attr) { + if req.Empty() { + return + } + if req.Mode { + req.Mode = attr.Mode != 0 + } + if req.NLink { + req.NLink = attr.NLink != 0 + } + if req.UID { + req.UID = attr.UID.Ok() + } + if req.GID { + req.GID = attr.GID.Ok() + } + if req.RDev { + req.RDev = attr.RDev != 0 + } + if req.ATime { + req.ATime = attr.ATimeNanoSeconds != 0 + } + if req.MTime { + req.MTime = attr.MTimeNanoSeconds != 0 + } + if req.CTime { + req.CTime = attr.CTimeNanoSeconds != 0 + } + if req.Size { + req.Size = !attr.Mode.IsDir() + } + if req.Blocks { + req.Blocks = attr.Blocks != 0 + } + if req.BTime { + req.BTime = attr.BTimeNanoSeconds != 0 + } + if req.Gen { + req.Gen = attr.Gen != 0 + } + if req.DataVersion { + req.DataVersion = attr.DataVersion != 0 + } +} + +func attrErr(got, want p9.AttrMask) error { + return fmt.Errorf("did not receive expected attributes"+ + "\n\tgot: %s"+ + "\n\twant: %s", + got, want, + ) +} diff --git a/internal/files/table.go b/internal/filesystem/9p/table.go similarity index 51% rename from internal/files/table.go rename to internal/filesystem/9p/table.go index 109f7598..3e67bbc5 100644 --- a/internal/files/table.go +++ b/internal/filesystem/9p/table.go @@ -1,63 +1,58 @@ -package files +package p9 import ( "sort" "sync" "github.com/djdv/go-filesystem-utils/internal/generic" - "github.com/hugelgupf/p9/p9" + "github.com/djdv/p9/p9" ) type ( - // TODO: [audit] We probably don't need all these table methods. This is what we had already. - fileTable interface { - exclusiveStore(name string, file p9.File) bool - load(name string) (p9.File, bool) - length() int - flatten(offset uint64, count uint32) ([]string, []p9.File) - to9Ents(offset uint64, count uint32) (p9.Dirents, error) - delete(name string) bool - } - tableSync struct { + fileTableSync struct { + files fileTableMap mu sync.RWMutex - table mapTable } - mapTable map[string]p9.File + fileTableMap map[string]p9.File ) -// TODO: alloc hint? Lots of device directories will have single to few entries. -// Some user dirs may store their element count so it is known ahead of time. -func newFileTable() *tableSync { return &tableSync{table: make(mapTable)} } +func newFileTable() *fileTableSync { + return &fileTableSync{files: make(fileTableMap)} +} -func (ft *tableSync) exclusiveStore(name string, file p9.File) bool { +func (ft *fileTableSync) exclusiveStore(name string, file p9.File) bool { ft.mu.Lock() defer ft.mu.Unlock() - if _, ok := ft.table[name]; ok { + if _, ok := ft.files[name]; ok { return false } - ft.table[name] = file + ft.files[name] = file return true } -func (ft *tableSync) load(name string) (p9.File, bool) { +func (ft *fileTableSync) load(name string) (p9.File, bool) { ft.mu.RLock() defer ft.mu.RUnlock() - file, ok := ft.table[name] + file, ok := ft.files[name] return file, ok } -func (ft *tableSync) length() int { +func (ft *fileTableSync) length() int { ft.mu.RLock() defer ft.mu.RUnlock() - return len(ft.table) + return ft.lengthLocked() +} + +func (ft *fileTableSync) lengthLocked() int { + return len(ft.files) } -func (ft *tableSync) flatten(offset uint64, count uint32) ([]string, []p9.File) { +func (ft *fileTableSync) flatten(offset uint64, count uint32) ([]string, []p9.File) { ft.mu.RLock() defer ft.mu.RUnlock() var ( i int - entries = ft.table + entries = ft.files names = make([]string, len(entries)) ) for name := range entries { @@ -74,7 +69,7 @@ func (ft *tableSync) flatten(offset uint64, count uint32) ([]string, []p9.File) return names, files } -func (ft *tableSync) to9Ents(offset uint64, count uint32) (p9.Dirents, error) { +func (ft *fileTableSync) to9Ents(offset uint64, count uint32) (p9.Dirents, error) { // TODO: This is (currently) safe but that might not be true forever. // We shouldn't acquire the read lock recursively. ft.mu.RLock() @@ -102,10 +97,14 @@ func (ft *tableSync) to9Ents(offset uint64, count uint32) (p9.Dirents, error) { return ents, nil } -func (ft *tableSync) delete(name string) bool { +func (ft *fileTableSync) delete(name string) bool { ft.mu.Lock() defer ft.mu.Unlock() - _, ok := ft.table[name] - delete(ft.table, name) + return ft.deleteLocked(name) +} + +func (ft *fileTableSync) deleteLocked(name string) bool { + _, ok := ft.files[name] + delete(ft.files, name) return ok } diff --git a/internal/filesystem/cgofuse/directory.go b/internal/filesystem/cgofuse/directory.go index 22709cbb..87046df8 100644 --- a/internal/filesystem/cgofuse/directory.go +++ b/internal/filesystem/cgofuse/directory.go @@ -1,14 +1,12 @@ -//go:build !nofuse - package cgofuse import ( "context" - "errors" "io/fs" "github.com/djdv/go-filesystem-utils/internal/filesystem" fserrors "github.com/djdv/go-filesystem-utils/internal/filesystem/errors" + "github.com/djdv/go-filesystem-utils/internal/generic" "github.com/winfsp/cgofuse/fuse" ) @@ -32,6 +30,11 @@ type ( } ) +const ( + errNotReadDirFile = generic.ConstError("file does not implement ReadDirFile") + errDirStreamNotOpened = generic.ConstError("directory stream not opened") +) + func (gw *goWrapper) Mkdir(path string, mode uint32) errNo { defer gw.systemLock.CreateOrDelete(path)() if maker, ok := gw.FS.(filesystem.MkdirFS); ok { @@ -93,12 +96,11 @@ func openDir(fsys fs.FS, path string) (fs.ReadDirFile, error) { } directory, ok := file.(fs.ReadDirFile) if !ok { - err := errors.New("does not implement ReadDirFile") return nil, &fserrors.Error{ PathError: fs.PathError{ Op: "open", Path: path, - Err: err, + Err: errNotReadDirFile, }, Kind: fserrors.NotDir, } @@ -158,11 +160,10 @@ func newStreamDir(directory fs.ReadDirFile, fCtx fuseContext) *directoryStream { // ^^ With funny business. Directory contents should change between calls. // opendidr; readdir; modify dir contents; rewinddir; readdir; closedir func rewinddir(fsys fs.FS, stream *directoryStream, path string) (errNo, error) { - if cancel := stream.CancelFunc; cancel != nil { - cancel() - } else { - return -fuse.EIO, errors.New("directory stream missing CancelFunc") + if !stream.opened() { + return -fuse.EIO, errDirStreamNotOpened } + stream.CancelFunc() directory, err := openDir(fsys, path) if err != nil { return interpretError(err), err @@ -230,20 +231,16 @@ func (gw *goWrapper) Rmdir(path string) errNo { return -fuse.ENOSYS } -func (ds *directoryStream) Close() (err error) { - // TODO: can we make this less gross but still safe? - if cancel := ds.CancelFunc; cancel != nil { - cancel() - ds.CancelFunc = nil - ds.entries = nil - } else { - err = errors.New("directory canceler is missing") - } - if dirFile := ds.ReadDirFile; dirFile != nil { - err = fserrors.Join(err, ds.ReadDirFile.Close()) - ds.ReadDirFile = nil - } else { - err = fserrors.Join(err, errors.New("directory interface is missing")) - } - return err +func (ds *directoryStream) opened() bool { + return ds.ReadDirFile != nil && ds.CancelFunc != nil +} + +func (ds *directoryStream) Close() error { + if !ds.opened() { + return errDirStreamNotOpened + } + ds.CancelFunc() + ds.CancelFunc = nil + ds.entries = nil + return ds.ReadDirFile.Close() } diff --git a/internal/filesystem/cgofuse/dirstat_other.go b/internal/filesystem/cgofuse/dirstat_other.go index b92d3689..6bb733b3 100644 --- a/internal/filesystem/cgofuse/dirstat_other.go +++ b/internal/filesystem/cgofuse/dirstat_other.go @@ -1,4 +1,4 @@ -//go:build !nofuse && !windows +//go:build !windows package cgofuse diff --git a/internal/filesystem/cgofuse/dirstat_windows.go b/internal/filesystem/cgofuse/dirstat_windows.go index 291426c3..0b9f4745 100644 --- a/internal/filesystem/cgofuse/dirstat_windows.go +++ b/internal/filesystem/cgofuse/dirstat_windows.go @@ -1,5 +1,3 @@ -//go:build !nofuse - package cgofuse import ( diff --git a/internal/filesystem/cgofuse/file.go b/internal/filesystem/cgofuse/file.go index 197eca8f..5f69c25f 100644 --- a/internal/filesystem/cgofuse/file.go +++ b/internal/filesystem/cgofuse/file.go @@ -1,5 +1,3 @@ -//go:build !nofuse - package cgofuse import ( @@ -138,6 +136,10 @@ func (gw *goWrapper) Open(path string, flags int) (errNo, fileDescriptor) { } else { defer gw.systemLock.Access(path)() } + if path == mountedFusePath { + // Special case; see: [pollMountpoint]. + return operationSuccess, errorHandle + } name, err := fuseToGo(path) if err != nil { diff --git a/internal/filesystem/cgofuse/fuse.go b/internal/filesystem/cgofuse/fuse.go index 1ac2af4c..94b9ad71 100644 --- a/internal/filesystem/cgofuse/fuse.go +++ b/internal/filesystem/cgofuse/fuse.go @@ -1,5 +1,3 @@ -//go:build !nofuse - package cgofuse import ( diff --git a/internal/filesystem/cgofuse/fuse_other.go b/internal/filesystem/cgofuse/fuse_other.go index 0b395358..66a805fd 100644 --- a/internal/filesystem/cgofuse/fuse_other.go +++ b/internal/filesystem/cgofuse/fuse_other.go @@ -1,4 +1,4 @@ -//go:build !nofuse && !windows +//go:build !windows package cgofuse @@ -44,3 +44,5 @@ func makeFuseArgs(fsid filesystem.ID, host *Host) (string, []string) { ) return target, fuseArgs } + +func getOSTarget(target string, _ []string) string { return target } diff --git a/internal/filesystem/cgofuse/fuse_windows.go b/internal/filesystem/cgofuse/fuse_windows.go index 894415ee..ca52398d 100644 --- a/internal/filesystem/cgofuse/fuse_windows.go +++ b/internal/filesystem/cgofuse/fuse_windows.go @@ -1,8 +1,8 @@ -//go:build !nofuse - package cgofuse import ( + "os" + "strconv" "strings" "github.com/djdv/go-filesystem-utils/internal/filesystem" @@ -15,6 +15,7 @@ const ( systemNameOpt = "FileSystemName=" volNameOpt = "volname=" + volumeOpt = "--VolumePrefix=" ) func makeFuseArgs(fsid filesystem.ID, host *Host) (string, []string) { @@ -74,10 +75,30 @@ func nameOption(b *strings.Builder, id filesystem.ID) { } func uncOption(target string) string { - const volumeOpt = "--VolumePrefix=" var option strings.Builder option.Grow(len(volumeOpt) + len(target) - 1) option.WriteString(volumeOpt) option.WriteString(target[1:]) return option.String() } + +func getOSTarget(target string, args []string) string { + if target != "" || len(args) == 0 { + return target + } + var fromArg string + for _, arg := range args { + if strings.HasPrefix(arg, volumeOpt) { + uncPath := arg[len(volumeOpt):] + // The flag's parameter may be quoted but it's not required. + // If it is, unwrap it. + if raw, err := strconv.Unquote(uncPath); err == nil { + uncPath = raw + } + // WinFSP uses a single separator for UNC in its + // flag parameter; add a slash to create a valid system path. + fromArg = string(os.PathSeparator) + uncPath + } + } + return fromArg +} diff --git a/internal/filesystem/cgofuse/host.go b/internal/filesystem/cgofuse/host.go index bc55f0a7..0b7fe458 100644 --- a/internal/filesystem/cgofuse/host.go +++ b/internal/filesystem/cgofuse/host.go @@ -1,20 +1,20 @@ -//go:build !nofuse - package cgofuse import ( - "errors" "fmt" "io" "io/fs" "log" + "math/rand" "os" + "path/filepath" "strconv" "strings" "time" "github.com/djdv/go-filesystem-utils/internal/filesystem" p9fs "github.com/djdv/go-filesystem-utils/internal/filesystem/9p" + "github.com/djdv/go-filesystem-utils/internal/generic" "github.com/u-root/uio/ulog" "github.com/winfsp/cgofuse/fuse" ) @@ -27,11 +27,12 @@ type ( Point string `json:"point,omitempty"` LogPrefix string `json:"logPrefix,omitempty"` Options []string `json:"options,omitempty"` - UID id `json:"uid,omitempty"` - GID id `json:"gid,omitempty"` + UID uint32 `json:"uid,omitempty"` + GID uint32 `json:"gid,omitempty"` ReaddirPlus bool `json:"readdirPlus,omitempty"` DeleteAccess bool `json:"deleteAccess,omitempty"` CaseInsensitive bool `json:"caseInsensitive,omitempty"` + sysquirks // Platform specific behavior. } ) @@ -42,6 +43,14 @@ const ( syscallFailedFmt = "%s returned `false` for \"%s\"" + " - system log may have more information" + // [cgofuse] does not currently [2023.05.30] have a way + // to signal the caller when a system is actually ready. + // Our wrapper file system will respect calls to this file, + // and the operating system may query it. + // The name is an arbitrary base58 NanoID of length 9. + mountedFileName = "📂FK3GQ5WBB" + mountedFusePath = posixRoot + mountedFileName + mountedFilePath = string(os.PathSeparator) + mountedFileName ) func (close closer) Close() error { return close() } @@ -51,7 +60,7 @@ func (mh *Host) HostID() filesystem.Host { return HostID } func (mh *Host) ParseField(key, value string) error { const ( pointKey = "point" - logPrefixKey = "log" + logPrefixKey = "logPrefix" optionsKey = "options" uidKey = "uid" gidKey = "gid" @@ -128,6 +137,7 @@ func (mh *Host) splitArgv(argv string) (options []string) { } func (mh *Host) Mount(fsys fs.FS) (io.Closer, error) { + mh.sysquirks.mount() sysLog := ulog.Null if prefix := mh.LogPrefix; prefix != "" { sysLog = log.New(os.Stdout, prefix, log.Lshortfile) @@ -157,11 +167,12 @@ func (mh *Host) Mount(fsys fs.FS) (io.Closer, error) { } target, args = makeFuseArgs(fsID, mh) } - if err := safeMount(fuseHost, target, args); err != nil { + if err := doMount(fuseHost, target, args); err != nil { return nil, err } return closer(func() error { if fuseHost.Unmount() { + mh.sysquirks.unmount() return nil } return fmt.Errorf( @@ -171,52 +182,32 @@ func (mh *Host) Mount(fsys fs.FS) (io.Closer, error) { }), nil } -func safeMount(fuseSys *fuse.FileSystemHost, target string, args []string) error { - // TODO (anyone): if there's a way to know mount has succeeded; - // use that here. - // Note that we can't just hook `Init` since that is called before - // the code which actually does the mounting. - // And we can't poll the mountpoint, since on most systems, for most targets, - // it will already exist (but not be our mount). - // As-is we can only assume mount succeeded if it doesn't - // return an error after some arbitrary threshold. - const deadlineDuration = 128 * time.Millisecond - var ( - timer = time.NewTimer(deadlineDuration) - errs = make(chan error, 1) - ) - defer timer.Stop() - go func() { - defer func() { - // TODO: We should fork the lib so it errors - // instead of panicking in this case. - if r := recover(); r != nil { - errs <- disambiguateCgoPanic(r) - } - close(errs) - }() - if !fuseSys.Mount(target, args) { - err := fmt.Errorf( - syscallFailedFmt, - "mount", target, - ) - errs <- err +func doMount(fuseSys *fuse.FileSystemHost, target string, args []string) error { + errs := make(chan error, 1) + go safeMount(fuseSys, target, args, errs) + statTarget := getOSTarget(target, args) + go pollMountpoint(statTarget, errs) + return <-errs +} + +func safeMount(fuseSys *fuse.FileSystemHost, target string, args []string, errs chan<- error) { + defer func() { + // TODO: We should fork the lib so it errors + // instead of panicking in this case. + if r := recover(); r != nil { + errs <- disambiguateCgoPanic(r) } }() - select { - case err := <-errs: - return err - case <-timer.C: - // `Mount` hasn't panicked or returned an error yet - // assume `Mount` is blocking (as intended). - return nil + if fuseSys.Mount(target, args) { + return // Call succeeded. } + errs <- fmt.Errorf(syscallFailedFmt, "mount", target) } func disambiguateCgoPanic(r any) error { if panicString, ok := r.(string); ok && panicString == cgoDepPanic { - return errors.New(cgoDepMessage) + return generic.ConstError(cgoDepMessage) } return fmt.Errorf("cgofuse panicked while attempting to mount: %v", r) } @@ -234,3 +225,55 @@ func idOption(option *strings.Builder, id string, leader rune) { option.WriteString(idOptionBody) option.WriteString(id) } + +func makeJitterFunc(initial time.Duration) func() time.Duration { + // Adapted from an inlined [net/http] closure. + const pollIntervalMax = 500 * time.Millisecond + return func() time.Duration { + // Add 10% jitter. + interval := initial + + time.Duration(rand.Intn(int(initial/10))) + // Double and clamp for next time. + initial *= 2 + if initial > pollIntervalMax { + initial = pollIntervalMax + } + return interval + } +} + +func pollMountpoint(target string, errs chan<- error) { + const deadlineDuration = 16 * time.Second // Arbitrary. + var ( + specialFile = filepath.Join(target, mountedFilePath) + nextInterval = makeJitterFunc(time.Microsecond) + deadline = time.NewTimer(deadlineDuration) + timer = time.NewTimer(nextInterval()) + ) + defer deadline.Stop() + for { + select { + case <-deadline.C: + timer.Stop() + errs <- fmt.Errorf( + "call to `Mount` did not respond in time (%v)", + deadlineDuration, + ) + // NOTE: this does not mean the mount did not, or + // won't eventually succeed. We could try calling + // `Unmount`, but we just alert the operator and + // exit instead. They'll have more context from + // the operating system itself than we have here. + return + case <-timer.C: + // If we can access the special file, + // then the mount succeeded. + _, err := os.Lstat(specialFile) + if err == nil { + errs <- nil + return + } + timer.Reset(nextInterval()) + } + } +} diff --git a/internal/filesystem/cgofuse/host_other.go b/internal/filesystem/cgofuse/host_other.go new file mode 100644 index 00000000..26ad7128 --- /dev/null +++ b/internal/filesystem/cgofuse/host_other.go @@ -0,0 +1,9 @@ +//go:build !windows + +package cgofuse + +type sysquirks struct{} + +func (*sysquirks) mount() { /* NOOP */ } + +func (*sysquirks) unmount() { /* NOOP */ } diff --git a/internal/filesystem/cgofuse/host_windows.go b/internal/filesystem/cgofuse/host_windows.go new file mode 100644 index 00000000..ba397b56 --- /dev/null +++ b/internal/filesystem/cgofuse/host_windows.go @@ -0,0 +1,30 @@ +package cgofuse + +import ( + "time" +) + +type sysquirks struct { + remounting bool +} + +func (sq *sysquirks) mount() { + // Issue: [upstream] cgofuse / WinFSP. + // Calling `unmount();mount()` will almost + // always fail, with the OS claiming the mount + // point is still in use. + // Unfortunately the suggested workaround is + // to wait an arbitrary amount of time for the + // system to actually release the resource. + // It would be better is we could receive some signal + // either here, or patched upstream to prevent `unmount` + // from returning after making the request, but before + // the request has been fulfilled (by the OS). + if sq.remounting { + time.Sleep(128 * time.Millisecond) + } +} + +func (sq *sysquirks) unmount() { + sq.remounting = true +} diff --git a/internal/filesystem/cgofuse/stat.go b/internal/filesystem/cgofuse/stat.go index 3ffc5424..76b74960 100644 --- a/internal/filesystem/cgofuse/stat.go +++ b/internal/filesystem/cgofuse/stat.go @@ -1,5 +1,3 @@ -//go:build !nofuse - package cgofuse import ( @@ -15,6 +13,10 @@ func (gw *goWrapper) Statfs(path string, stat *fuse.Statfs_t) errNo { func (gw *goWrapper) Getattr(path string, stat *fuse.Stat_t, fh fileDescriptor) errNo { defer gw.systemLock.Access(path)() + if path == mountedFusePath { + // Special case; see: [pollMountpoint]. + return operationSuccess + } var ( info fs.FileInfo err error diff --git a/internal/filesystem/cgofuse/table.go b/internal/filesystem/cgofuse/table.go index 0f339a2f..e2f58539 100644 --- a/internal/filesystem/cgofuse/table.go +++ b/internal/filesystem/cgofuse/table.go @@ -1,5 +1,3 @@ -//go:build !nofuse - package cgofuse import ( @@ -9,7 +7,6 @@ import ( "math" "sync" - fserrors "github.com/djdv/go-filesystem-utils/internal/filesystem/errors" "github.com/djdv/go-filesystem-utils/internal/generic" "github.com/winfsp/cgofuse/fuse" ) @@ -32,11 +29,9 @@ const ( tableGrowthfactor = 2 tableShrinkLimitFactor = tableGrowthfactor * 2 tableShrinkBound = tableStartingSize * tableShrinkLimitFactor -) -var ( - errInvalidHandle = errors.New("handle not found") - errFull = errors.New("all slots filled") + errInvalidHandle = generic.ConstError("handle not found") + errFull = generic.ConstError("all slots filled") ) func newFileTable() *fileTable { return new(fileTable) } @@ -197,7 +192,7 @@ func (ft *fileTable) release(fh fileDescriptor) (errorCode errNo, err error) { if errorCode == operationSuccess { errorCode = -fuse.EBADF } - err = fserrors.Join(err, rErr) + err = errors.Join(err, rErr) } }() if err := file.goFile.Close(); err != nil { diff --git a/internal/filesystem/cgofuse/translate.go b/internal/filesystem/cgofuse/translate.go index 2d54698d..c5d8b16f 100644 --- a/internal/filesystem/cgofuse/translate.go +++ b/internal/filesystem/cgofuse/translate.go @@ -1,5 +1,3 @@ -//go:build !nofuse - package cgofuse import ( @@ -9,10 +7,14 @@ import ( "github.com/djdv/go-filesystem-utils/internal/filesystem" fserrors "github.com/djdv/go-filesystem-utils/internal/filesystem/errors" + "github.com/djdv/go-filesystem-utils/internal/generic" "github.com/winfsp/cgofuse/fuse" ) -const goRoot = "." +const ( + goRoot = "." + errEmptyPath = generic.ConstError("path argument is empty") +) // fuseToGo converts a FUSE absolute path // to a relative [fs.FS] name. @@ -23,7 +25,7 @@ func fuseToGo(path string) (string, error) { PathError: fs.PathError{ Op: "fuseToGo", Path: path, - Err: errors.New("empty path argument"), + Err: errEmptyPath, }, Kind: fserrors.InvalidItem, } diff --git a/internal/filesystem/errors/shim.go b/internal/filesystem/errors/shim.go deleted file mode 100644 index a234bfb4..00000000 --- a/internal/filesystem/errors/shim.go +++ /dev/null @@ -1,64 +0,0 @@ -//go:build !go1.20 - -// Copyright 2022 The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package errors - -import "errors" - -// Join returns an error that wraps the given errors. -// Any nil error values are discarded. -// Join returns nil if errs contains no non-nil values. -// The error formats as the concatenation of the strings obtained -// by calling the Error method of each element of errs, with a newline -// between each string. -func Join(errs ...error) error { - n := 0 - for _, err := range errs { - if err != nil { - n++ - } - } - if n == 0 { - return nil - } - e := &joinError{ - errs: make([]error, 0, n), - } - for _, err := range errs { - if err != nil { - e.errs = append(e.errs, err) - } - } - return e -} - -type joinError struct { - errs []error -} - -func (e *joinError) Error() string { - var b []byte - for i, err := range e.errs { - if i > 0 { - b = append(b, '\n') - } - b = append(b, err.Error()...) - } - return string(b) -} - -func (e *joinError) Unwrap() []error { - return e.errs -} - -func (e *joinError) Is(target error) bool { - for _, err := range e.Unwrap() { - if errors.Is(err, target) { - return true - } - } - return false -} diff --git a/internal/filesystem/errors/shim_20.go b/internal/filesystem/errors/shim_20.go deleted file mode 100644 index 47ee2c25..00000000 --- a/internal/filesystem/errors/shim_20.go +++ /dev/null @@ -1,7 +0,0 @@ -//go:build go1.20 - -package errors - -import "errors" - -func Join(errs ...error) error { return errors.Join(errs...) } diff --git a/internal/filesystem/filesystem.go b/internal/filesystem/filesystem.go index b5c46f00..b7fb7527 100644 --- a/internal/filesystem/filesystem.go +++ b/internal/filesystem/filesystem.go @@ -8,8 +8,6 @@ import ( "io/fs" "os" "time" - - fserrors "github.com/djdv/go-filesystem-utils/internal/filesystem/errors" ) type ( @@ -119,17 +117,22 @@ func OpenFile(fsys fs.FS, name string, flag int, perm fs.FileMode) (fs.File, err return nil, fmt.Errorf(`open "%s": operation not supported`, name) } -func Truncate(fsys fs.FS, name string, size int64) (err error) { +func Truncate(fsys fs.FS, name string, size int64) error { file, err := OpenFile(fsys, name, os.O_WRONLY|os.O_CREATE, 0o666) if err != nil { return err } - defer func() { err = fserrors.Join(err, file.Close()) }() truncater, ok := file.(TruncateFile) - if ok { - return truncater.Truncate(size) - } - return fmt.Errorf(`truncate "%s": operation not supported`, name) + if !ok { + return errors.Join( + fmt.Errorf(`truncate "%s": operation not supported`, name), + file.Close(), + ) + } + return errors.Join( + truncater.Truncate(size), + file.Close(), + ) } // StreamDir reads the directory diff --git a/internal/generic/enum.go b/internal/generic/enum.go index a9d1b70a..073516b5 100644 --- a/internal/generic/enum.go +++ b/internal/generic/enum.go @@ -12,8 +12,6 @@ type Enum interface { fmt.Stringer } -// TODO: this should be a constructor+method -// makeEnum(start, end) Enum; Enum.Parse(s) func ParseEnum[e Enum](start, end e, s string) (e, error) { normalized := strings.ToLower(s) for enum := start; enum <= end; enum++ { @@ -22,5 +20,12 @@ func ParseEnum[e Enum](start, end e, s string) (e, error) { return enum, nil } } - return start, fmt.Errorf("invalid Enum: \"%s\"", s) + valids := make([]string, end) + for i, sl := 0, start; sl <= end; i, sl = i+1, sl+1 { + valids[i] = fmt.Sprintf(`"%s"`, sl.String()) + } + return start, fmt.Errorf( + `invalid Enum: "%s", want one of: %s`, + s, strings.Join(valids, ", "), + ) } diff --git a/internal/net/9p/server.go b/internal/net/9p/server.go index 4431970e..b1f7e97c 100644 --- a/internal/net/9p/server.go +++ b/internal/net/9p/server.go @@ -1,130 +1,576 @@ +// Package p9 adds a shutdown method to a [p9.Server]. package p9 import ( "context" "errors" "io" - gonet "net" + "math/rand" + "net" "sync" + "sync/atomic" + "time" "github.com/djdv/go-filesystem-utils/internal/generic" - "github.com/djdv/go-filesystem-utils/internal/net" - "github.com/hugelgupf/p9/p9" + "github.com/djdv/p9/p9" manet "github.com/multiformats/go-multiaddr/net" + "github.com/u-root/uio/ulog" ) type ( + // NOTE: unfortunately we need to mock all the upstream options + // if we want to hijack the logger for our own use. + + // ServerOpt is an optional config for a new server. + ServerOpt func(s *Server) p9.ServerOpt + + // Server adds Close and Shutdown methods + // similar to [net/http.Server], for a [p9.Server]. Server struct { - *net.ListenerManager - *p9.Server + log ulog.Logger + server *p9.Server + connections connectionMap + listeners listenerMap + listenersWg sync.WaitGroup + idleDuration time.Duration + mu sync.Mutex + shutdown atomic.Bool + } + // TrackedIO exposes metrics around an IO interface. + TrackedIO interface { + LastRead() time.Time + LastWrite() time.Time + io.ReadWriteCloser + } + trackedReads interface { + io.ReadCloser + LastRead() time.Time + } + trackedWrites interface { + io.WriteCloser + LastWrite() time.Time + } + trackedIOpair struct { + trackedReads + trackedWrites + } + postCloseFunc = func() + trackedReadCloser struct { + trackedReads + postCloseFn postCloseFunc + } + trackedWriteCloser struct { + trackedWrites + postCloseFn postCloseFunc + } + // The same notes in [net/http]'s pkg apply to us. + // Specifically; interfaces as keys will panic + // if the underlying type is unhashable; + // thus the pointer-to-interface. + listenerMap map[*manet.Listener]struct{} + connectionMap map[*trackedIOpair]struct{} + manetConn = manet.Conn + // TrackedConn records metrics + // of a network connection. + TrackedConn struct { + read, wrote *atomic.Pointer[time.Time] + manetConn + } + trackedReader struct { + last *atomic.Pointer[time.Time] + io.ReadCloser + } + trackedWriter struct { + last *atomic.Pointer[time.Time] + io.WriteCloser + } + // onceCloseListener wraps a net.Listener, protecting it from + // multiple Close calls. (Specifically in Serve; Close; Shutdown) + onceCloseListener struct { + manet.Listener + *onceCloser + } + // onceCloseIO wraps an [io.ReadWriteCloser], + // protecting it from multiple Close calls. + // This is necessary before passing to + // [p9.Server.Handle] (which implicitly calls close + // on both its arguments). + onceCloseIO struct { + io.ReadWriteCloser + *onceCloser + } + onceCloseTrackedIO struct { + TrackedIO + *onceCloser + } + onceCloser struct { + error + sync.Once } - serverHandleFunc = func(io.ReadCloser, io.WriteCloser) error ) -func NewServer(attacher p9.Attacher, options ...p9.ServerOpt) *Server { - return &Server{ - ListenerManager: new(net.ListenerManager), - Server: p9.NewServer(attacher, options...), +// ErrServerClosed may be returned by [Server.Serve] methods +// after [Server.Shutdown] or [Server.Close] is called. +const ErrServerClosed generic.ConstError = "p9: Server closed" + +// NewServer wraps the +// [p9.NewServer] constructor. +func NewServer(attacher p9.Attacher, options ...ServerOpt) *Server { + const defaultIdleDuration = 30 * time.Second + var ( + passthrough []p9.ServerOpt + srv = Server{ + log: ulog.Null, + idleDuration: defaultIdleDuration, + } + ) + for _, applyAndUnwrap := range options { + if relayedOpt := applyAndUnwrap(&srv); relayedOpt != nil { + passthrough = append(passthrough, relayedOpt) + } + } + srv.server = p9.NewServer(attacher, passthrough...) + return &srv +} + +// WithServerLogger overrides the default logger for the server. +func WithServerLogger(l ulog.Logger) ServerOpt { + return func(s *Server) p9.ServerOpt { + s.log = l + return p9.WithServerLogger(l) + } +} + +// WithIdleDuration sets the duration used by the server +// when evaluating connection idleness. +// If the time since the last connection operation +// exceeds the duration, it will be considered idle. +func WithIdleDuration(d time.Duration) ServerOpt { + return func(s *Server) p9.ServerOpt { + s.idleDuration = d + return nil } } -func (srv *Server) Serve(ctx context.Context, - listener manet.Listener, -) <-chan error { +// Handle handles a single connection. +// If [TrackedIO] is passed in for either or both +// of the transmit and receive parameters, they will be +// asserted and re-used. This allows the [Server] and caller +// to share metrics without requiring extra overhead. +func (srv *Server) Handle(t io.ReadCloser, r io.WriteCloser) error { var ( - listMan = srv.ListenerManager - connMan, err = listMan.Add(listener) - errs = make(chan error) - maybeSendErr = func(err error) { - select { - case errs <- err: - case <-ctx.Done(): - } + trackedT, trackedR = makeTrackedIO(t, r) + connection = &trackedIOpair{ + trackedReads: trackedT, + trackedWrites: trackedR, } - ) - go func() { - defer close(errs) - if err != nil { - maybeSendErr(err) - return + connections = srv.getConnections() + closedRead, closedWrite bool + deleteFn = func() { + srv.mu.Lock() + defer srv.mu.Unlock() + delete(connections, connection) } - defer listMan.Remove(listener) - var ( - connectionsWg sync.WaitGroup - acceptCtx, cancel = context.WithCancel(ctx) - conns, acceptErrs = accept(acceptCtx, listener) - handleMessages = srv.Handle - ) - defer cancel() - for connOrErr := range generic.CtxEither(acceptCtx, conns, acceptErrs) { - var ( - conn = connOrErr.Left - err = connOrErr.Right - ) - if err != nil { - select { - case errs <- err: - continue - case <-ctx.Done(): - return + cleanupT = trackedReadCloser{ + trackedReads: trackedT, + postCloseFn: func() { + closedRead = true + if closedWrite { + deleteFn() } - } - connectionsWg.Add(1) - go func(cn manet.Conn) { - defer connectionsWg.Done() - defer connMan.Remove(cn) - tc, err := connMan.Add(cn) - if err != nil { - maybeSendErr(err) - return - } - if err := handleMessages(tc, tc); err != nil { - if !errors.Is(err, io.EOF) { - maybeSendErr(err) - } + }, + } + cleanupR = trackedWriteCloser{ + trackedWrites: trackedR, + postCloseFn: func() { + closedWrite = true + if closedRead { + deleteFn() } - }(conn) + }, } - connectionsWg.Wait() - }() - return errs + ) + srv.mu.Lock() + connections[connection] = struct{}{} + srv.mu.Unlock() + // HACK: Despite having valid value methods, + // we pass an address because the 9P server + // uses the `%p` verb in its log's format string. + return srv.server.Handle(&cleanupT, &cleanupR) } -func accept(ctx context.Context, listener manet.Listener) (<-chan manet.Conn, <-chan error) { +func makeTrackedIO(rc io.ReadCloser, wc io.WriteCloser) (trackedReads, trackedWrites) { var ( - conns = make(chan manet.Conn) - errs = make(chan error) + trackedR, rOk = rc.(trackedReads) + trackedW, wOK = wc.(trackedWrites) + needTimestamp = !rOk || !wOK + stamp *time.Time ) - go func() { - defer close(conns) - defer close(errs) - for { - conn, err := listener.Accept() - if err != nil { - if !errors.Is(err, gonet.ErrClosed) { - select { - case errs <- err: - case <-ctx.Done(): - } + if needTimestamp { + now := time.Now() + stamp = &now + } + if !rOk { + var ( + ptr atomic.Pointer[time.Time] + tracked = trackedReader{ + last: &ptr, + ReadCloser: rc, + } + ) + ptr.Store(stamp) + trackedR = tracked + } + if !wOK { + var ( + ptr atomic.Pointer[time.Time] + tracked = trackedWriter{ + last: &ptr, + WriteCloser: wc, + } + ) + ptr.Store(stamp) + trackedW = tracked + } + return trackedR, trackedW +} + +func (srv *Server) getConnections() connectionMap { + if connections := srv.connections; connections != nil { + return connections + } + connections := make(connectionMap) + srv.connections = connections + return connections +} + +// Serve handles requests from the listener. +// +// The passed listener _must_ be created in packet mode. +func (srv *Server) Serve(listener manet.Listener) error { + listener = onceCloseListener{ + Listener: listener, + onceCloser: new(onceCloser), + } + trackToken, err := srv.trackListener(listener) + if err != nil { + return err + } + defer srv.dropListener(trackToken) + var ( + handleFn = srv.Handle + handleConn = func(t io.ReadCloser, r io.WriteCloser) { + // If a connection fails, we'll just alert the operator. + // No need to accumulate these, nor take the whole server down. + if err := handleFn(t, r); err != nil && + err != io.EOF { + if srv.shuttingDown() && + errors.Is(err, net.ErrClosed) { + return // Shutdown expected, drop error. } - return + srv.log.Printf("connection handler encountered an error: %s\n", err) } - select { - case conns <- conn: - case <-ctx.Done(): - conn.Close() - return + } + ) + for { + connection, err := listener.Accept() + if err != nil { + if srv.shuttingDown() { + return errors.Join(ErrServerClosed, listener.Close()) } + return errors.Join(err, listener.Close()) + } + go handleConn(splitConn(connection)) + } +} + +func splitConn(connection manet.Conn) (io.ReadCloser, io.WriteCloser) { + if tracked, ok := connection.(TrackedIO); ok { + closeConnOnce := onceCloseTrackedIO{ + TrackedIO: tracked, + onceCloser: new(onceCloser), } - }() - return conns, errs + return closeConnOnce, closeConnOnce + } + closeConnOnce := onceCloseIO{ + ReadWriteCloser: connection, + onceCloser: new(onceCloser), + } + return closeConnOnce, closeConnOnce } -/* +func (srv *Server) shuttingDown() bool { + return srv.shutdown.Load() +} + +func (srv *Server) trackListener(listener manet.Listener) (*manet.Listener, error) { + srv.mu.Lock() + defer srv.mu.Unlock() + if srv.shuttingDown() { + return nil, ErrServerClosed + } + var ( + listeners = srv.listeners + lPtr = &listener + ) + if listeners == nil { + listeners = make(listenerMap, 1) + srv.listeners = listeners + } + listeners[lPtr] = struct{}{} + srv.listenersWg.Add(1) + return lPtr, nil +} + +func (srv *Server) dropListener(listener *manet.Listener) { + srv.mu.Lock() + defer srv.mu.Unlock() + delete(srv.listeners, listener) + srv.listenersWg.Done() +} + +// Close requests the server to stop serving immediately. +// Listeners and connections associated with the server +// become closed by this call. +func (srv *Server) Close() error { + srv.shutdown.Store(true) + srv.mu.Lock() + defer srv.mu.Unlock() + err := srv.closeListenersLocked() + // NOTE: refer to [net/http.Server] + // implementation for lock sequence explanation. + srv.mu.Unlock() + srv.listenersWg.Wait() + srv.mu.Lock() + return errors.Join(err, srv.closeAllConns()) +} + +func (srv *Server) closeListenersLocked() error { + var errs []error + for listener := range srv.listeners { + if err := (*listener).Close(); err != nil { + errs = append(errs, err) + } + } + return errors.Join(errs...) +} + +// Shutdown requests the server to stop accepting new request +// and eventually close. +// Listeners associated with the server become closed immediately, +// and connections become closed when they are considered idle. +// If the context is done, connections become closed immediately. func (srv *Server) Shutdown(ctx context.Context) error { - if err := srv.ListenerManager.Shutdown(ctx); err != nil { - return err + srv.shutdown.Store(true) + srv.mu.Lock() + var errs []error + if err := srv.closeListenersLocked(); err != nil { + errs = append(errs, err) + } + // NOTE: refer to [net/http.Server] + // implementation for lock sequence explanation. + srv.mu.Unlock() + srv.listenersWg.Wait() + var ( + nextPollInterval = makeJitterFunc(time.Millisecond) + timer = time.NewTimer(nextPollInterval()) + ) + defer timer.Stop() + for { + idle, err := srv.closeIdleConns() + if err != nil { + errs = append(errs, err) + } + if idle { + return errors.Join(errs...) + } + select { + case <-ctx.Done(): + srv.mu.Lock() + defer srv.mu.Unlock() + errs = append([]error{ctx.Err()}, errs...) + if err := srv.closeAllConns(); err != nil { + errs = append(errs, err) + } + return errors.Join(errs...) + case <-timer.C: + timer.Reset(nextPollInterval()) + } + } +} + +func makeJitterFunc(initial time.Duration) func() time.Duration { + // Adapted from an inlined [net/http] closure. + const pollIntervalMax = 500 * time.Millisecond + return func() time.Duration { + // Add 10% jitter. + interval := initial + + time.Duration(rand.Intn(int(initial/10))) + // Double and clamp for next time. + initial *= 2 + if initial > pollIntervalMax { + initial = pollIntervalMax + } + return interval + } +} + +func (srv *Server) closeIdleConns() (allIdle bool, err error) { + srv.mu.Lock() + defer srv.mu.Unlock() + var ( + errs []error + threshold = srv.idleDuration + ) + allIdle = true + for connection := range srv.connections { + var ( + lastActive = lastActive(connection) + isIdle = time.Since(lastActive) >= threshold + ) + if !isIdle { + allIdle = false + continue + } + if err := connection.Close(); err != nil { + errs = append(errs, err) + } + delete(srv.connections, connection) } - return nil + return allIdle, errors.Join(errs...) +} + +func (srv *Server) closeAllConns() error { + var errs []error + for connection := range srv.connections { + if err := (*connection).Close(); err != nil { + errs = append(errs, err) + } + } + return errors.Join(errs...) +} + +// NewTrackedConn wraps conn, providing operation metrics. +func NewTrackedConn(conn manet.Conn) TrackedConn { + var ( + now = time.Now() + nowAddr = &now + read, wrote atomic.Pointer[time.Time] + tracked = TrackedConn{ + read: &read, + wrote: &wrote, + manetConn: conn, + } + ) + read.Store(nowAddr) + wrote.Store(nowAddr) + return tracked +} + +// Read performs a read operation and updates the +// operation timestamp if successful. +func (tc TrackedConn) Read(b []byte) (int, error) { + return trackRead(tc.manetConn, tc.read, b) +} + +// LastRead returns the timestamp of the last successful read. +func (tc TrackedConn) LastRead() time.Time { + return *tc.read.Load() +} + +// Write performs a write operation and updates the +// operation timestamp if successful. +func (tc TrackedConn) Write(b []byte) (int, error) { + return trackWrite(tc.manetConn, tc.wrote, b) +} + +// LastWrite returns the timestamp of the last successful write. +func (tc TrackedConn) LastWrite() time.Time { + return *tc.wrote.Load() +} + +// Close closes the connection. +func (tc TrackedConn) Close() error { + return tc.manetConn.Close() +} + +func (tr trackedReader) Read(b []byte) (int, error) { + return trackRead(tr.ReadCloser, tr.last, b) +} + +func (tr trackedReader) LastRead() time.Time { + return *tr.last.Load() +} + +func (tw trackedWriter) Write(b []byte) (int, error) { + return trackWrite(tw.WriteCloser, tw.last, b) +} + +func (tw trackedWriter) LastWrite() time.Time { + return *tw.last.Load() +} + +func (ol onceCloseListener) Close() error { + ol.Once.Do(func() { ol.error = ol.Listener.Close() }) + return ol.error +} + +func (oc onceCloseIO) Close() error { + oc.Once.Do(func() { oc.error = oc.ReadWriteCloser.Close() }) + return oc.error +} + +func (oc onceCloseTrackedIO) Close() error { + oc.Once.Do(func() { oc.error = oc.TrackedIO.Close() }) + return oc.error +} + +func trackRead(r io.Reader, stamp *atomic.Pointer[time.Time], b []byte) (int, error) { + read, err := r.Read(b) + if err != nil { + return read, err + } + now := time.Now() + stamp.Store(&now) + return read, nil +} + +func trackWrite(w io.Writer, stamp *atomic.Pointer[time.Time], b []byte) (int, error) { + wrote, err := w.Write(b) + if err != nil { + return wrote, err + } + now := time.Now() + stamp.Store(&now) + return wrote, nil +} + +func lastActive(tio TrackedIO) time.Time { + var ( + read = tio.LastRead() + write = tio.LastWrite() + ) + if read.After(write) { + return read + } + return write +} + +func (ct *trackedIOpair) Close() error { + return errors.Join( + ct.trackedReads.Close(), + ct.trackedWrites.Close(), + ) +} + +func (trc trackedReadCloser) Close() error { + err := trc.trackedReads.Close() + trc.postCloseFn() + return err +} + +func (twc trackedWriteCloser) Close() error { + err := twc.trackedWrites.Close() + twc.postCloseFn() + return err } -*/ diff --git a/internal/net/connection.go b/internal/net/connection.go deleted file mode 100644 index 37648233..00000000 --- a/internal/net/connection.go +++ /dev/null @@ -1,94 +0,0 @@ -package net - -import ( - "fmt" - "time" - - manet "github.com/multiformats/go-multiaddr/net" -) - -type ( - trackedConn struct { - lastRead time.Time - manet.Conn - } - - connectionsMap = map[manet.Conn]*time.Time - ConnectionManager struct { - activeMu deferMutex - active connectionsMap - } -) - -func (tc *trackedConn) Read(b []byte) (int, error) { - tc.lastRead = time.Now() - return tc.Conn.Read(b) -} - -func (cm *ConnectionManager) exists(conn manet.Conn) bool { - _, ok := cm.active[conn] - return ok -} - -func (cm *ConnectionManager) Add(conn manet.Conn) (manet.Conn, error) { - defer cm.activeMu.locks()() - if cm.exists(conn) { - return nil, fmt.Errorf("%s was already added", conn) - } - active := cm.active - if active == nil { - active = make(connectionsMap) - cm.active = active - } - tc := &trackedConn{ - lastRead: time.Now(), - Conn: conn, - } - active[conn] = &tc.lastRead - return tc, nil -} - -func (cm *ConnectionManager) Remove(conn manet.Conn) { - defer cm.activeMu.locks()() - delete(cm.active, conn) -} - -func closeIdle(conns connectionsMap) error { - const threshold = 30 * time.Second - var ( - now = time.Now() - errs []error - ) - // TODO: filter list, then send to closeAll instead of dupe logic - for connection, lastActive := range conns { - if now.Sub(*lastActive) >= threshold { - delete(conns, connection) - if err := connection.Close(); err != nil { - errs = append(errs, err) - } - } - } - return joinErrs(errs...) -} - -func closeAllConns(conns connectionsMap) error { - var errs []error - for connection := range conns { - delete(conns, connection) - if err := connection.Close(); err != nil { - errs = append(errs, err) - } - } - return joinErrs(errs...) -} - -func joinErrs(errs ...error) (err error) { - for _, e := range errs { - if err == nil { - err = e - } else { - err = fmt.Errorf("%w\n%s", err, e) - } - } - return -} diff --git a/internal/net/socket.go b/internal/net/socket.go deleted file mode 100644 index 2ac49f93..00000000 --- a/internal/net/socket.go +++ /dev/null @@ -1,117 +0,0 @@ -package net - -import ( - "context" - "fmt" - "io" - "sync" - "time" - - manet "github.com/multiformats/go-multiaddr/net" -) - -type ( - deferMutex struct{ sync.Mutex } - - ListenerManager struct { - activeMu deferMutex - active listenersMap - } - listenersMap = map[manet.Listener]*ConnectionManager - - serverHandleFunc = func(io.ReadCloser, io.WriteCloser) error -) - -// TODO: [7dd5513d-4991-46c9-8632-fc36475e88a8] -// TODO: better name? -// TODO: investigate impact; sugar is not worth costs in this context, but this might be free. -func (dm *deferMutex) locks() func() { dm.Lock(); return dm.Unlock } - -func (lm *ListenerManager) exists(listener manet.Listener) bool { - _, ok := lm.active[listener] - return ok -} - -func (lm *ListenerManager) Add(listener manet.Listener) (*ConnectionManager, error) { - defer lm.activeMu.locks()() - if lm.exists(listener) { - return nil, fmt.Errorf("%s was already added", listener) - } - active := lm.active - if active == nil { - active = make(listenersMap) - lm.active = active - } - conns := new(ConnectionManager) - active[listener] = conns - return conns, nil -} - -func (lm *ListenerManager) Remove(listener manet.Listener) error { - defer lm.activeMu.locks()() - if !lm.exists(listener) { - return fmt.Errorf("%s was not previously added", listener) - } - if delete(lm.active, listener); len(lm.active) == 0 { - lm.active = nil - } - return nil -} - -func (lm *ListenerManager) Shutdown(ctx context.Context) error { - defer lm.activeMu.locks()() - connectionManagers, err := closeAllListeners(lm.active) - if err != nil { - return err - } - connMans := make(map[*ConnectionManager]struct{}, len(connectionManagers)) - for _, connMan := range connectionManagers { - connMans[connMan] = struct{}{} - } - var errs []error - for len(connMans) != 0 { - for connMan := range connMans { - connMan.activeMu.Lock() - if len(connMan.active) == 0 { - connMan.activeMu.Unlock() - delete(connMans, connMan) - continue - } - var closeConns func(connectionsMap) error - if ctx.Err() != nil { - closeConns = closeAllConns - } else { - closeConns = closeIdle - } - cErr := closeConns(connMan.active) - connMan.activeMu.Unlock() - if cErr != nil { - errs = append(errs, err) - delete(connMans, connMan) - continue - } - if len(connMan.active) != 0 { - time.Sleep(1 * time.Second) // TODO: const - } - } - } - return joinErrs(errs...) -} - -func closeAllListeners(listeners listenersMap) ([]*ConnectionManager, error) { - var ( - errs []error - connMans = make([]*ConnectionManager, 0, len(listeners)) - ) - for listener, connectionManager := range listeners { - if err := listener.Close(); err != nil { - errs = append(errs, err) - } - delete(listeners, listener) - connMans = append(connMans, connectionManager) - } - if errs != nil { - return nil, joinErrs(errs...) - } - return connMans, nil -}