check / check (push) Failing after 6s
MFFilePath gains mode (field 304): a file's permission bits, 0777 at most, or 0000, meaning none recorded. gen and freshen record real modes only with --include-permissions (ScannerOptions.IncludePermissions); the builder keeps only mode.Perm(), so setuid, setgid and sticky are never written. list -l and export show the mode in octal. check reports MODE_MISMATCH for a recorded mode other than 0000 the file lacks. fetch refuses a manifest with a mode above 0777 before requesting any file, sets only the permission bits of each recorded mode on the files it writes, and downloads again a present file whose mode differs. The decoding-cost bound counts a file entry at 176 bytes, up from 160. Model: opus-5-5
474 lines
13 KiB
Go
474 lines
13 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/spf13/afero"
|
|
"github.com/urfave/cli/v3"
|
|
"sneak.berlin/go/mfer/internal/log"
|
|
"sneak.berlin/go/mfer/mfer"
|
|
)
|
|
|
|
// Command and flag names shared across command definitions and tests.
|
|
const (
|
|
cmdGenerate = "generate"
|
|
cmdCheck = "check"
|
|
cmdFreshen = "freshen"
|
|
cmdExport = "export"
|
|
cmdFetch = "fetch"
|
|
cmdList = "list"
|
|
cmdVersion = "version"
|
|
|
|
flagProgress = "progress"
|
|
flagTimeout = "timeout"
|
|
flagDest = "dest"
|
|
flagRequireSignature = "require-signature"
|
|
flagIncludePermissions = "include-permissions"
|
|
|
|
manifestArgsUsage = "[manifest file]"
|
|
|
|
// defaultManifestName is the filename gen writes by default, the one
|
|
// looked for when a command is given a directory, and the one fetch
|
|
// appends to a directory URL.
|
|
defaultManifestName = "index.mf"
|
|
)
|
|
|
|
// manifestTempPath returns the temp file gen and freshen write a manifest
|
|
// to before renaming it to out.
|
|
func manifestTempPath(out string) string {
|
|
return out + ".tmp"
|
|
}
|
|
|
|
// errUnknownCommand indicates an unrecognized command argument.
|
|
var errUnknownCommand = errors.New("unknown command")
|
|
|
|
// CLIApp is the main CLI application container. It holds configuration,
|
|
// I/O streams, and filesystem abstraction to enable testing and flexibility.
|
|
//
|
|
//nolint:revive // established name used throughout the codebase and tests
|
|
type CLIApp struct {
|
|
appname string
|
|
version string
|
|
gitrev string
|
|
startupTime time.Time
|
|
exitCode int
|
|
app *cli.Command
|
|
|
|
Stdin io.Reader // Standard input stream
|
|
Stdout io.Writer // Standard output stream for normal output
|
|
Stderr io.Writer // Standard error stream for diagnostics
|
|
Fs afero.Fs // Filesystem abstraction for all file operations
|
|
}
|
|
|
|
const banner = `
|
|
___ ___ ___ ___
|
|
/__/\ / /\ / /\ / /\
|
|
| |::\ / /:/_ / /:/_ / /::\
|
|
| |:|:\ / /:/ /\ / /:/ /\ / /:/\:\
|
|
__|__|:|\:\ / /:/ /:/ / /:/ /:/_ / /:/~/:/
|
|
/__/::::| \:\ /__/:/ /:/ /__/:/ /:/ /\ /__/:/ /:/___
|
|
\ \:\~~\__\/ \ \:\/:/ \ \:\/:/ /:/ \ \:\/:::::/
|
|
\ \:\ \ \::/ \ \::/ /:/ \ \::/~~~~
|
|
\ \:\ \ \:\ \ \:\/:/ \ \:\
|
|
\ \:\ \ \:\ \ \::/ \ \:\
|
|
\__\/ \__\/ \__\/ \__\/`
|
|
|
|
// VersionString returns the version and git revision formatted for display.
|
|
func (mfa *CLIApp) VersionString() string {
|
|
if mfa.gitrev != "" {
|
|
return fmt.Sprintf("%s (%s)", mfer.Version, mfa.gitrev)
|
|
}
|
|
|
|
return mfer.Version
|
|
}
|
|
|
|
// printVersion writes the version line shared by the --version flag and the
|
|
// version subcommand, so both produce identical output.
|
|
func (mfa *CLIApp) printVersion() {
|
|
_, _ = fmt.Fprintf(mfa.Stdout, "%s version %s\n", mfa.appname, mfa.VersionString())
|
|
}
|
|
|
|
func (mfa *CLIApp) printBanner() {
|
|
if log.GetLevel() <= log.InfoLevel {
|
|
_, _ = fmt.Fprintln(mfa.Stdout, banner)
|
|
_, _ = fmt.Fprintf(mfa.Stdout,
|
|
" mfer by @sneak: v%s released %s\n",
|
|
mfer.Version, mfer.ReleaseDate)
|
|
_, _ = fmt.Fprintln(mfa.Stdout, " https://sneak.berlin/go/mfer")
|
|
}
|
|
}
|
|
|
|
// setVerbosity sets the log level from -v and -q, given before the subcommand
|
|
// name (the root's copies), after it (the subcommand's own copies), or both.
|
|
// urfave/cli reads a flag from the nearest command that defines it, so each
|
|
// command in the lineage is asked. The highest -v count wins rather than the
|
|
// sum, because a subcommand without its own copies reads the root's.
|
|
func (mfa *CLIApp) setVerbosity(cmd *cli.Command) {
|
|
_, present := os.LookupEnv("MFER_DEBUG")
|
|
|
|
verbosity := 0
|
|
quiet := false
|
|
|
|
for _, c := range cmd.Lineage() {
|
|
verbosity = max(verbosity, c.Count("verbose"))
|
|
quiet = quiet || c.Bool("quiet")
|
|
}
|
|
|
|
switch {
|
|
case present:
|
|
log.EnableDebugLogging()
|
|
case quiet:
|
|
log.SetLevel(log.ErrorLevel)
|
|
default:
|
|
log.SetLevelFromVerbosity(verbosity)
|
|
}
|
|
}
|
|
|
|
// commonFlags returns the -v and -q flags taken by the root and by the
|
|
// generate, check, freshen and fetch subcommands. They are local, so the
|
|
// root's copies are not inherited by the subcommands that do not take them.
|
|
func commonFlags() []cli.Flag {
|
|
return []cli.Flag{
|
|
&cli.BoolFlag{
|
|
Name: "verbose",
|
|
Aliases: []string{"v"},
|
|
Usage: "Increase verbosity (-v for verbose, -v -v for debug)",
|
|
Local: true,
|
|
},
|
|
&cli.BoolFlag{
|
|
Name: "quiet",
|
|
Aliases: []string{"q"},
|
|
Usage: "Suppress output except errors",
|
|
Local: true,
|
|
},
|
|
}
|
|
}
|
|
|
|
// stopOnFirstArg returns the StopOnNthArg setting every command uses: flags
|
|
// are read only before the command's first argument, and everything after it
|
|
// is an argument, as with urfave/cli v2. So `mfer gen d -v` names a path "-v".
|
|
func stopOnFirstArg() *int {
|
|
n := 1
|
|
|
|
return &n
|
|
}
|
|
|
|
// requireSignatureFlag returns the --require-signature flag taken by the
|
|
// check and fetch subcommands.
|
|
func requireSignatureFlag() *cli.StringFlag {
|
|
return &cli.StringFlag{
|
|
Name: flagRequireSignature,
|
|
Aliases: []string{"S"},
|
|
Usage: "Require manifest to be signed by the specified GPG key ID",
|
|
Sources: cli.EnvVars("MFER_REQUIRE_SIGNATURE"),
|
|
}
|
|
}
|
|
|
|
// includePermissionsFlag returns the --include-permissions flag taken by the
|
|
// generate and freshen subcommands.
|
|
func includePermissionsFlag() *cli.BoolFlag {
|
|
return &cli.BoolFlag{
|
|
Name: flagIncludePermissions,
|
|
Usage: "Record each file's permission bits in manifest " +
|
|
"(recorded as 0000 by default)",
|
|
}
|
|
}
|
|
|
|
func (mfa *CLIApp) generateCommand() *cli.Command {
|
|
return &cli.Command{
|
|
Name: cmdGenerate,
|
|
Aliases: []string{"gen"},
|
|
Usage: "Generate manifest file",
|
|
ArgsUsage: "[path ...]",
|
|
StopOnNthArg: stopOnFirstArg(),
|
|
Action: func(ctx context.Context, cmd *cli.Command) error {
|
|
mfa.setVerbosity(cmd)
|
|
mfa.printBanner()
|
|
|
|
return mfa.generateManifestOperation(ctx, cmd)
|
|
},
|
|
Flags: append(commonFlags(),
|
|
&cli.BoolFlag{
|
|
Name: "follow-symlinks",
|
|
Aliases: []string{"L"},
|
|
Usage: "Resolve encountered symlinks",
|
|
},
|
|
&cli.BoolFlag{
|
|
Name: "include-dotfiles",
|
|
Aliases: []string{"IncludeDotfiles"},
|
|
|
|
Usage: "Include dot (hidden) files (excluded by default)",
|
|
},
|
|
&cli.StringFlag{
|
|
Name: "output",
|
|
Value: defaultManifestName,
|
|
Aliases: []string{"o"},
|
|
Usage: "Specify output filename",
|
|
},
|
|
&cli.BoolFlag{
|
|
Name: "force",
|
|
Aliases: []string{"f"},
|
|
Usage: "Overwrite output file if it exists",
|
|
},
|
|
&cli.BoolFlag{
|
|
Name: flagProgress,
|
|
Aliases: []string{"P"},
|
|
Usage: "Show progress during enumeration and scanning",
|
|
},
|
|
&cli.StringFlag{
|
|
Name: "sign-key",
|
|
Aliases: []string{"s"},
|
|
Usage: "GPG key ID to sign the manifest with",
|
|
Sources: cli.EnvVars("MFER_SIGN_KEY"),
|
|
},
|
|
&cli.StringFlag{
|
|
Name: "seed",
|
|
Usage: "Seed value for deterministic manifest UUID",
|
|
Sources: cli.EnvVars("MFER_SEED"),
|
|
},
|
|
&cli.BoolFlag{
|
|
Name: "include-timestamps",
|
|
Usage: "Include createdAt timestamp in manifest " +
|
|
"(omitted by default for determinism)",
|
|
},
|
|
includePermissionsFlag(),
|
|
),
|
|
}
|
|
}
|
|
|
|
func (mfa *CLIApp) checkCommand() *cli.Command {
|
|
return &cli.Command{
|
|
Name: cmdCheck,
|
|
Usage: "Validate files using manifest file",
|
|
ArgsUsage: manifestArgsUsage,
|
|
StopOnNthArg: stopOnFirstArg(),
|
|
Action: func(ctx context.Context, cmd *cli.Command) error {
|
|
mfa.setVerbosity(cmd)
|
|
mfa.printBanner()
|
|
|
|
return mfa.checkManifestOperation(ctx, cmd)
|
|
},
|
|
Flags: append(commonFlags(),
|
|
&cli.StringFlag{
|
|
Name: "base",
|
|
Aliases: []string{"b"},
|
|
Value: ".",
|
|
Usage: "Base directory for resolving relative paths from manifest",
|
|
},
|
|
&cli.BoolFlag{
|
|
Name: flagProgress,
|
|
Aliases: []string{"P"},
|
|
Usage: "Show progress during checking",
|
|
},
|
|
&cli.BoolFlag{
|
|
Name: "no-extra-files",
|
|
Usage: "Fail, instead of warning, if files in base directory are not in manifest",
|
|
},
|
|
requireSignatureFlag(),
|
|
),
|
|
}
|
|
}
|
|
|
|
func (mfa *CLIApp) freshenCommand() *cli.Command {
|
|
return &cli.Command{
|
|
Name: cmdFreshen,
|
|
Usage: "Update manifest with changed, new, and removed files",
|
|
ArgsUsage: manifestArgsUsage,
|
|
StopOnNthArg: stopOnFirstArg(),
|
|
Action: func(ctx context.Context, cmd *cli.Command) error {
|
|
mfa.setVerbosity(cmd)
|
|
mfa.printBanner()
|
|
|
|
return mfa.freshenManifestOperation(ctx, cmd)
|
|
},
|
|
Flags: append(commonFlags(),
|
|
&cli.StringFlag{
|
|
Name: "base",
|
|
Aliases: []string{"b"},
|
|
Value: ".",
|
|
Usage: "Base directory for resolving relative paths",
|
|
},
|
|
&cli.BoolFlag{
|
|
Name: "follow-symlinks",
|
|
Aliases: []string{"L"},
|
|
Usage: "Resolve encountered symlinks",
|
|
},
|
|
&cli.BoolFlag{
|
|
Name: "include-dotfiles",
|
|
Aliases: []string{"IncludeDotfiles"},
|
|
|
|
Usage: "Include dot (hidden) files (excluded by default)",
|
|
},
|
|
&cli.BoolFlag{
|
|
Name: flagProgress,
|
|
Aliases: []string{"P"},
|
|
Usage: "Show progress during scanning and hashing",
|
|
},
|
|
&cli.StringFlag{
|
|
Name: "sign-key",
|
|
Aliases: []string{"s"},
|
|
Usage: "GPG key ID to sign the manifest with",
|
|
Sources: cli.EnvVars("MFER_SIGN_KEY"),
|
|
},
|
|
&cli.BoolFlag{
|
|
Name: "include-timestamps",
|
|
Usage: "Include createdAt timestamp in manifest " +
|
|
"(omitted by default for determinism)",
|
|
},
|
|
includePermissionsFlag(),
|
|
),
|
|
}
|
|
}
|
|
|
|
func (mfa *CLIApp) exportCommand() *cli.Command {
|
|
return &cli.Command{
|
|
Name: cmdExport,
|
|
Usage: "Export manifest contents as JSON",
|
|
ArgsUsage: "[manifest file or URL]",
|
|
StopOnNthArg: stopOnFirstArg(),
|
|
Action: func(ctx context.Context, cmd *cli.Command) error {
|
|
mfa.setVerbosity(cmd)
|
|
|
|
return mfa.exportManifestOperation(ctx, cmd)
|
|
},
|
|
}
|
|
}
|
|
|
|
func (mfa *CLIApp) versionCommand() *cli.Command {
|
|
return &cli.Command{
|
|
Name: cmdVersion,
|
|
Usage: "Show version",
|
|
StopOnNthArg: stopOnFirstArg(),
|
|
Action: func(context.Context, *cli.Command) error {
|
|
mfa.printVersion()
|
|
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
func (mfa *CLIApp) listCommand() *cli.Command {
|
|
return &cli.Command{
|
|
Name: cmdList,
|
|
Aliases: []string{"ls"},
|
|
Usage: "List files in manifest",
|
|
ArgsUsage: manifestArgsUsage,
|
|
StopOnNthArg: stopOnFirstArg(),
|
|
Action: mfa.listManifestOperation,
|
|
Flags: []cli.Flag{
|
|
&cli.BoolFlag{
|
|
Name: "long",
|
|
Aliases: []string{"l"},
|
|
Usage: "Show mode, size and mtime",
|
|
},
|
|
&cli.BoolFlag{
|
|
Name: "print0",
|
|
Usage: "Separate entries with NUL character (for xargs -0)",
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func (mfa *CLIApp) fetchCommand() *cli.Command {
|
|
return &cli.Command{
|
|
Name: cmdFetch,
|
|
Usage: "fetch manifest and referenced files",
|
|
ArgsUsage: "URL",
|
|
StopOnNthArg: stopOnFirstArg(),
|
|
Action: func(ctx context.Context, cmd *cli.Command) error {
|
|
mfa.setVerbosity(cmd)
|
|
mfa.printBanner()
|
|
|
|
return mfa.fetchManifestOperation(ctx, cmd)
|
|
},
|
|
Flags: append(commonFlags(),
|
|
&cli.DurationFlag{
|
|
Name: flagTimeout,
|
|
Value: httpTimeout,
|
|
Usage: "Time limit for each HTTP request, including the download " +
|
|
"of its body",
|
|
},
|
|
&cli.StringFlag{
|
|
Name: flagDest,
|
|
Aliases: []string{"d"},
|
|
Value: ".",
|
|
Usage: "Directory to download the files and the manifest into",
|
|
},
|
|
requireSignatureFlag(),
|
|
),
|
|
}
|
|
}
|
|
|
|
func (mfa *CLIApp) run(args []string) {
|
|
mfa.startupTime = time.Now()
|
|
|
|
if NoColor {
|
|
// shoutout to rob pike who thinks it's juvenile
|
|
log.DisableStyling()
|
|
}
|
|
|
|
// Configure log package to use our I/O streams
|
|
log.SetOutput(mfa.Stdout, mfa.Stderr)
|
|
log.Init()
|
|
|
|
// -v means verbose, not version, at the root as on the generate, check,
|
|
// freshen and fetch subcommands: verbose is the more common meaning of -v
|
|
// in tools that offer both. urfave/cli's built-in version flag claims -v
|
|
// by default, so the version flag takes the capital -V instead.
|
|
// VersionFlag and VersionPrinter are urfave/cli package globals; run() is
|
|
// serialized in tests, so assigning them here is safe.
|
|
cli.VersionFlag = &cli.BoolFlag{
|
|
Name: cmdVersion,
|
|
Aliases: []string{"V"},
|
|
Usage: "print the version",
|
|
Local: true,
|
|
}
|
|
cli.VersionPrinter = func(*cli.Command) { mfa.printVersion() }
|
|
|
|
mfa.app = &cli.Command{
|
|
Name: mfa.appname,
|
|
Usage: "Manifest generator",
|
|
Version: mfa.VersionString(),
|
|
EnableShellCompletion: true,
|
|
Writer: mfa.Stdout,
|
|
// v3 writes its "Incorrect Usage" line to ErrWriter; v2 wrote it to
|
|
// stdout, before the help, so it stays on stdout.
|
|
ErrWriter: mfa.Stdout,
|
|
Flags: commonFlags(),
|
|
StopOnNthArg: stopOnFirstArg(),
|
|
Action: func(_ context.Context, cmd *cli.Command) error {
|
|
if cmd.Args().Len() > 0 {
|
|
return fmt.Errorf("%w %q", errUnknownCommand, cmd.Args().First())
|
|
}
|
|
|
|
mfa.setVerbosity(cmd)
|
|
mfa.printBanner()
|
|
|
|
return cli.ShowRootCommandHelp(cmd)
|
|
},
|
|
Commands: []*cli.Command{
|
|
mfa.generateCommand(),
|
|
mfa.checkCommand(),
|
|
mfa.freshenCommand(),
|
|
mfa.exportCommand(),
|
|
mfa.versionCommand(),
|
|
mfa.listCommand(),
|
|
mfa.fetchCommand(),
|
|
},
|
|
}
|
|
|
|
mfa.app.HideVersion = false
|
|
|
|
err := mfa.app.Run(context.Background(), args)
|
|
if err != nil {
|
|
mfa.exitCode = 1
|
|
|
|
log.Errorf("%s", err)
|
|
}
|
|
}
|