Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion cmd/drive9/cli/cat.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ func Cat(c *client.Client, args []string) error {
}

func catWithWriter(c *client.Client, args []string, out io.Writer) error {
if IsHelpArgs(args) {
_, _ = fmt.Fprintln(out, fsCatUsage())
return nil
}
fs := flag.NewFlagSet("fs cat", flag.ContinueOnError)
offset := fs.Int64("offset", 0, "byte offset for a positional read; requires --length")
length := fs.Int64("length", 0, "byte length for a positional read; requires --offset")
Expand All @@ -38,7 +42,7 @@ func catWithWriter(c *client.Client, args []string, out io.Writer) error {
})

if fs.NArg() != 1 {
return fmt.Errorf("usage: drive9 fs cat [--offset N --length N] <path>")
return fmt.Errorf("%s", fsCatUsage())
}
if offsetSet != lengthSet {
return fmt.Errorf("--offset and --length must be provided together")
Expand Down
16 changes: 16 additions & 0 deletions cmd/drive9/cli/cat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,22 @@ func TestCatWritesFullFile(t *testing.T) {
}
}

func TestCatHelpWritesToInjectedWriter(t *testing.T) {
var out bytes.Buffer
stdout, err := captureStdoutE(t, func() error {
return catWithWriter(client.New("http://127.0.0.1", ""), []string{"--help"}, &out)
})
if err != nil {
t.Fatalf("cat help: %v", err)
}
if stdout != "" {
t.Fatalf("stdout = %q, want empty", stdout)
}
if got := strings.TrimSpace(out.String()); got != fsCatUsage() {
t.Fatalf("writer output = %q, want %q", got, fsCatUsage())
}
}

func TestCatRangeWritesRequestedBytes(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
Expand Down
7 changes: 6 additions & 1 deletion cmd/drive9/cli/chmod.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,20 @@ package cli

import (
"fmt"
"os"
"strconv"

"github.com/mem9-ai/dat9/pkg/client"
)

// Chmod updates the permission bits of a remote file.
func Chmod(c *client.Client, args []string) error {
if IsHelpArgs(args) {
_, _ = fmt.Fprintln(os.Stdout, fsChmodUsage())
return nil
}
if len(args) != 2 {
return fmt.Errorf("usage: drive9 fs chmod <mode> <path>")
return fmt.Errorf("%s", fsChmodUsage())
}
modeStr, path := args[0], args[1]
mode64, err := strconv.ParseUint(modeStr, 8, 32)
Expand Down
86 changes: 84 additions & 2 deletions cmd/drive9/cli/cli_consolidation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"io"
"os"
"strconv"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -33,6 +34,59 @@ func captureStderrE(t *testing.T, fn func() error) (string, error) {
return string(<-done), fnErr
}

func TestIsHelpArgsScansBeforeDashDash(t *testing.T) {
for _, tc := range []struct {
name string
args []string
want bool
}{
{name: "empty", args: nil, want: false},
{name: "bare help is data outside command dispatch", args: []string{"help"}, want: false},
{name: "long help first arg", args: []string{"--help"}, want: true},
{name: "dash prefixed help flag value position", args: []string{"--from-file", "--help"}, want: true},
{name: "short help after flag", args: []string{"--name", "-h"}, want: true},
{name: "bare help after first arg is data", args: []string{"pattern", "help"}, want: false},
{name: "bare help as flag value is data", args: []string{"--name", "help"}, want: false},
{name: "bare help data before dash prefixed help keeps scanning", args: []string{"pattern", "help", "--help"}, want: true},
{name: "after dash dash is data", args: []string{"/n/vault/aws", "--", "env", "--help"}, want: false},
} {
t.Run(tc.name, func(t *testing.T) {
if got := IsHelpArgs(tc.args); got != tc.want {
t.Fatalf("IsHelpArgs(%v) = %v, want %v", tc.args, got, tc.want)
}
})
}
}

func TestCtxLeafHelpScansFullArgumentList(t *testing.T) {
for _, tc := range []struct {
name string
args []string
firstLine string
}{
{name: "show", args: []string{"show", "--json", "--help"}, firstLine: "usage: drive9 ctx show [--json] [--reveal]"},
{name: "add", args: []string{"add", "--api-key", "k", "--help"}, firstLine: "usage: drive9 ctx add --api-key <key> [--name <n>] [--server <url>]"},
{name: "import", args: []string{"import", "--name", "imported", "--help"}, firstLine: "usage: drive9 ctx import [--from-file <path|->] [--name <name>]"},
{name: "fork", args: []string{"fork", "child", "--help"}, firstLine: "usage: drive9 ctx fork [<new>] [--from <ctx>] [--json]"},
{name: "ls", args: []string{"ls", "--type", "--help"}, firstLine: "usage: drive9 ctx ls [-l|--json] [--type <kind>|--scoped]"},
{name: "use", args: []string{"use", "--help"}, firstLine: "usage: drive9 ctx use [--] <name>"},
{name: "rm", args: []string{"rm", "old", "--help"}, firstLine: "usage: drive9 ctx rm <name>"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("HOME", t.TempDir())
out, err := captureStdoutE(t, func() error {
return Ctx(tc.args)
})
if err != nil {
t.Fatalf("Ctx(%v): %v", tc.args, err)
}
if !strings.HasPrefix(out, tc.firstLine) {
t.Fatalf("stdout = %q, want first line %q", out, tc.firstLine)
}
})
}
}

// seedThreeContexts writes one owner + one delegated + one fs_scoped
// context to local config under an isolated $HOME for use by the
// ctx list / ctx rm / token deprecation alias tests in this file.
Expand Down Expand Up @@ -243,7 +297,7 @@ func TestCtxRmRemovesDashPrefixedContextNames(t *testing.T) {
}
}

func TestCtxRmRemovesHelpAliasContextNames(t *testing.T) {
func TestCtxRmRemovesEscapedHelpAliasContextNames(t *testing.T) {
for _, name := range []string{"help", "-h", "-help", "--help"} {
t.Run(name, func(t *testing.T) {
seedThreeContexts(t)
Expand All @@ -256,7 +310,7 @@ func TestCtxRmRemovesHelpAliasContextNames(t *testing.T) {
}

out, err := captureStdoutE(t, func() error {
return Ctx([]string{"rm", name})
return Ctx([]string{"rm", "--", name})
})
if err != nil {
t.Fatalf("ctx rm %q: %v", name, err)
Expand All @@ -271,6 +325,34 @@ func TestCtxRmRemovesHelpAliasContextNames(t *testing.T) {
}
}

func TestCtxUseUsesEscapedHelpAliasContextNames(t *testing.T) {
for _, name := range []string{"help", "-h", "-help", "--help"} {
t.Run(name, func(t *testing.T) {
seedThreeContexts(t)
cfg := loadConfig()
if _, err := ctxAdd(cfg, name, &Context{Type: PrincipalFSScoped, Server: "https://s", APIKey: "dat9_scoped", ExpiresAt: time.Now().Add(time.Hour)}); err != nil {
t.Fatalf("ctxAdd %q: %v", name, err)
}
if err := saveConfig(cfg); err != nil {
t.Fatalf("saveConfig: %v", err)
}

out, err := captureStdoutE(t, func() error {
return Ctx([]string{"use", "--", name})
})
if err != nil {
t.Fatalf("ctx use -- %q: %v", name, err)
}
if !strings.Contains(out, "switched to context "+strconv.Quote(name)) {
t.Fatalf("missing switch confirmation, got: %s", out)
}
if got := loadConfig().CurrentContext; got != name {
t.Fatalf("CurrentContext = %q, want %q", got, name)
}
})
}
}

// TestCtxRmOwnerRequiresConfirmation verifies the [y/N] prompt on
// owner-context removal. A "no" answer (empty line) MUST abort the
// removal so owner key loss is never accidental.
Expand Down
11 changes: 10 additions & 1 deletion cmd/drive9/cli/cp.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,24 @@ const recursiveCopyConcurrency = 16
// drive9 fs cp :/remote/path - download to stdout
// drive9 fs cp --append tail.log :/remote/path append local data to remote file
func Cp(c *client.Client, args []string) error {
if IsCpHelpArgs(args) {
_, _ = fmt.Fprintln(os.Stdout, fsCpUsage())
return nil
}
resume := false
appendMode := false
recursive := false
var tags map[string]string
var description string
filtered := make([]string, 0, len(args))
parseFlags := true
for i := 0; i < len(args); i++ {
a := args[i]
switch {
case parseFlags && a == "--":
parseFlags = false
case !parseFlags:
filtered = append(filtered, args[i])
case a == "--resume":
resume = true
case a == "--append":
Expand Down Expand Up @@ -84,7 +93,7 @@ func Cp(c *client.Client, args []string) error {
}

if len(args) != 2 {
return fmt.Errorf("usage: drive9 fs cp [-r|--recursive] [--resume] [--append] [--tag key=value]... [--description <text>] <src> <dst>")
return fmt.Errorf("%s", fsCpUsage())
}
if resume && appendMode {
return fmt.Errorf("--resume and --append cannot be used together")
Expand Down
105 changes: 105 additions & 0 deletions cmd/drive9/cli/cp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,111 @@ func TestCpUploadWithTagsSendsHeaders(t *testing.T) {
}
}

func TestCpDescriptionValueCanBeHelpLike(t *testing.T) {
localPath := filepath.Join(t.TempDir(), "upload.txt")
if err := os.WriteFile(localPath, []byte("hello description"), 0o644); err != nil {
t.Fatalf("WriteFile(local): %v", err)
}

var gotDescription string
var gotBody []byte
var putCalls int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method + " " + r.URL.Path {
case "PUT /v1/fs/described.txt":
putCalls++
gotDescription = r.Header.Get("X-Dat9-Description")
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
gotBody = append([]byte(nil), body...)
w.WriteHeader(http.StatusOK)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()

c := client.New(srv.URL, "")
c.SetSmallFileThresholdForTests(client.DefaultSmallFileThreshold)
out, err := captureStdoutE(t, func() error {
return Cp(c, []string{"--description", "--help", localPath, ":/described.txt"})
})
if err != nil {
t.Fatalf("Cp(upload with help-like description): %v", err)
}
if strings.Contains(out, "usage: drive9 fs cp") {
t.Fatalf("stdout = %q, want no cp usage", out)
}
if putCalls != 1 {
t.Fatalf("PUT calls = %d, want 1", putCalls)
}
if gotDescription != "--help" {
t.Fatalf("X-Dat9-Description = %q, want %q", gotDescription, "--help")
}
if got := string(gotBody); got != "hello description" {
t.Fatalf("uploaded body = %q, want %q", got, "hello description")
}
}

func TestCpTreatsHelpLikeSourcesAsData(t *testing.T) {
tmp := t.TempDir()
oldWD, err := os.Getwd()
if err != nil {
t.Fatalf("Getwd: %v", err)
}
if err := os.Chdir(tmp); err != nil {
t.Fatalf("Chdir(%q): %v", tmp, err)
}
t.Cleanup(func() {
if err := os.Chdir(oldWD); err != nil {
t.Fatalf("restore cwd: %v", err)
}
})
if err := os.WriteFile("help", []byte("bare help"), 0o644); err != nil {
t.Fatalf("WriteFile(help): %v", err)
}
if err := os.WriteFile("--help", []byte("dash help"), 0o644); err != nil {
t.Fatalf("WriteFile(--help): %v", err)
}

uploaded := map[string]string{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodHead:
http.NotFound(w, r)
case http.MethodPut:
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
uploaded[r.URL.Path] = string(body)
w.WriteHeader(http.StatusOK)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()

c := client.New(srv.URL, "")
c.SetSmallFileThresholdForTests(client.DefaultSmallFileThreshold)
if err := Cp(c, []string{"help", ":/bare.txt"}); err != nil {
t.Fatalf("Cp(bare help source): %v", err)
}
if err := Cp(c, []string{"--", "--help", ":/dash.txt"}); err != nil {
t.Fatalf("Cp(escaped --help source): %v", err)
}
if got := uploaded["/v1/fs/bare.txt"]; got != "bare help" {
t.Fatalf("bare upload body = %q, want %q", got, "bare help")
}
if got := uploaded["/v1/fs/dash.txt"]; got != "dash help" {
t.Fatalf("dash upload body = %q, want %q", got, "dash help")
}
}

func TestCpUploadToRemoteDirectoryKeepsSourceName(t *testing.T) {
localPath := filepath.Join(t.TempDir(), "upload.txt")
if err := os.WriteFile(localPath, []byte("hello dir"), 0o644); err != nil {
Expand Down
Loading
Loading