From f7ecb181ad5b1adcbf9bcef91a902a6cecda74b5 Mon Sep 17 00:00:00 2001 From: closer-claudio Date: Sun, 18 Oct 2026 22:28:35 -0700 Subject: [PATCH] tar: a long option may be any unambiguous prefix of its name GNU tar takes any unambiguous prefix of a long option (getopt_long), and bsdtar does too, so "tar xz --strip 1" extracts with both; this tar refused it as "unrecognized option '--strip'", exit 64. limen v0.14.0 puts this tar first on the hermetic PATH, where farcloser/tarmac's init pipes a tarball into exactly that command and failed on it. The long-option parser resolves the given name first: an option's whole name is that option, else the one option it is a prefix of; no option is unrecognized as before, several are "option '--ver' is ambiguous; possibilities: '--verbose' '--version'", GNU tar's wording, exit 64. A whole name wins over the options it prefixes: --file is --file, not ambiguous against --files-from, the one such pair in the tables. The argument rules are unchanged and name the resolved option, as glibc does. An empty name (--=x) is unrecognized under its own spelling. Tests: TestLongOptionAbbreviation creates with --cre --file --dir --verb --xz --files-from and extracts with --extr --strip 1 and --ext --strip=1 --directory, the tarmac shape; TestUsage gains the unrecognized prefix, two ambiguous ones (--ver, --same) and --=x. Each fails against the previous parser. Co-Authored-By: Claude Fable 5.1 Signed-off-by: closer-claudio --- README.md | 3 +- internal/tarcmd/doc.go | 3 +- internal/tarcmd/options.go | 65 +++++++++++++++++++++++++++++++++---- internal/tarcmd/run_test.go | 35 ++++++++++++++++++++ 4 files changed, 97 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index a60e988..456c54c 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,8 @@ guessing. `--no-same-permissions`, `--format`, `--posix`, `--owner`, `--group`, `--numeric-owner`, `--mtime`, `--sort`, `--no-recursion`, `--exclude`, `--delay-directory-restore`, `--force-local`. The old-style first argument - (`xf`, `cvzf`) and clustered short options work as in GNU tar; a read + (`xf`, `cvzf`) and clustered short options work as in GNU tar, and a long + option may be given as any unambiguous prefix of its name (`--strip 1`); a read without a compression flag picks the decoder by magic, and a known compressor given to `-I` (`zstd`, `zstdmt`, `unzstd`, `gzip`, `xz`, …) is the built-in codec, any other a filter run with `-d` to read. Extraction diff --git a/internal/tarcmd/doc.go b/internal/tarcmd/doc.go index da3e3e3..7fd94b8 100644 --- a/internal/tarcmd/doc.go +++ b/internal/tarcmd/doc.go @@ -12,7 +12,8 @@ // --help, --version. The // first argument may be an old-style option bundle (xf, cvzf); short options // cluster (-xzf); long options take their argument after '=' or as the next -// word. Anything else is a usage error. Members named for --extract or +// word and may be given as any unambiguous prefix of their name, as GNU tar +// and bsdtar take them. Anything else is a usage error. Members named for --extract or // --list, on the command line or in -T files, select the entries taken: a // name as the archive carries it, or a directory with everything under it; // one no entry carries is reported and the status is 2. diff --git a/internal/tarcmd/options.go b/internal/tarcmd/options.go index 7185c48..71c47bd 100644 --- a/internal/tarcmd/options.go +++ b/internal/tarcmd/options.go @@ -6,6 +6,7 @@ import ( "fmt" "os/user" "path/filepath" + "slices" "strconv" "strings" "time" @@ -238,10 +239,19 @@ func (o *options) shortWithValue(letter rune, value string) error { } // long applies one long option, given without its dashes, with its value -// after '=' or in the next word. It returns how many following words it -// consumed. +// after '=' or in the next word; the name may be any unambiguous prefix of +// an option's, as getopt_long takes it for GNU tar and as bsdtar takes it. +// It returns how many following words it consumed. func (o *options) long(spec string, following []string) (int, error) { - name, value, hasValue := strings.Cut(spec, "=") + given, value, hasValue := strings.Cut(spec, "=") + if given == "" { + return 0, usage("unrecognized option '--%s'", spec) + } + + name, err := resolveLong(given) + if err != nil { + return 0, err + } flag, isFlag := longFlags()[name] if isFlag { @@ -252,10 +262,7 @@ func (o *options) long(spec string, following []string) (int, error) { return 0, flag(o) } - withValue, takesValue := longWithValue()[name] - if !takesValue { - return 0, usage("unrecognized option '--%s'", name) - } + withValue := longWithValue()[name] if hasValue { return 0, withValue(o, value) @@ -268,6 +275,50 @@ func (o *options) long(spec string, following []string) (int, error) { return 1, withValue(o, following[0]) } +// resolveLong is the long option the given text names: itself when it is +// an option's whole name, else the one option it is a prefix of. No option +// is unrecognized; several are ambiguous, listed as GNU tar lists them. +func resolveLong(given string) (string, error) { + flags, withValue := longFlags(), longWithValue() + + if _, ok := flags[given]; ok { + return given, nil + } + + if _, ok := withValue[given]; ok { + return given, nil + } + + var found []string + + for name := range flags { + if strings.HasPrefix(name, given) { + found = append(found, name) + } + } + + for name := range withValue { + if strings.HasPrefix(name, given) { + found = append(found, name) + } + } + + switch len(found) { + case 0: + return "", usage("unrecognized option '--%s'", given) + case 1: + return found[0], nil + } + + slices.Sort(found) + + for i, name := range found { + found[i] = "'--" + name + "'" + } + + return "", usage("option '--%s' is ambiguous; possibilities: %s", given, strings.Join(found, " ")) +} + // setFlag is a long option without a value: it sets one field. func setFlag(set func(*options)) func(*options) error { return func(opts *options) error { diff --git a/internal/tarcmd/run_test.go b/internal/tarcmd/run_test.go index 3d5d914..8b5e74b 100644 --- a/internal/tarcmd/run_test.go +++ b/internal/tarcmd/run_test.go @@ -483,6 +483,10 @@ func TestUsage(t *testing.T) { want string }{ {[]string{"-cf", "a.tar", "--bogus"}, "unrecognized option '--bogus'"}, + {[]string{"-cf", "a.tar", "--strp", "y"}, "unrecognized option '--strp'"}, + {[]string{"-cf", "a.tar", "--ver", "y"}, "option '--ver' is ambiguous; possibilities: '--verbose' '--version'"}, + {[]string{"-cf", "a.tar", "--same", "y"}, "option '--same' is ambiguous; possibilities: '--same-owner' '--same-permissions'"}, + {[]string{"-cf", "a.tar", "--=x", "y"}, "unrecognized option '--=x'"}, {[]string{"-q"}, "invalid option -- 'q'"}, {[]string{"-cf"}, "option requires an argument -- 'f'"}, {[]string{"-c"}, "Cowardly refusing to create an empty archive"}, @@ -1036,3 +1040,34 @@ func TestCompressProgram(t *testing.T) { t.Errorf("a failing filter: exit %d, %q", status, stderr) } } + +// TestLongOptionAbbreviation: a long option may be given as any unambiguous +// prefix of its name, as getopt_long takes it for GNU tar and as bsdtar +// takes it, with or without its value after '='; a whole name wins over +// the options it is a prefix of, which is --file against --files-from, the +// one such pair in the tables, each taken as itself. farcloser/tarmac's +// init piped a tarball into "tar xz --strip 1" and was refused. +func TestLongOptionAbbreviation(t *testing.T) { + t.Parallel() + + src := tree(t) + list := filepath.Join(t.TempDir(), "list") + writeFile(t, list, "sub\n", 0o644) + + stdout, _ := mustRun(t, nil, "--cre", "--file", "-", "--dir", src, "--verb", "--xz", "--files-from", list) + if !strings.HasPrefix(stdout, "\xfd7zXZ") { + t.Fatalf("--xz archive starts %q", stdout[:min(6, len(stdout))]) + } + + for _, args := range [][]string{ + {"--extr", "--strip", "1", "--xz", "-C"}, + {"--ext", "--strip=1", "-J", "--directory"}, + } { + dst := t.TempDir() + mustRun(t, []byte(stdout), append(args, dst)...) + + if got := readFile(t, filepath.Join(dst, "run.sh")); !strings.Contains(got, "#!") { + t.Errorf("tar %v: run.sh after --strip 1 reads %q", args, got) + } + } +}