diff --git a/README.md b/README.md index 42f3f3a..5bc80cc 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,18 @@ Published to 34.126.161.115:33211 (Singapore) in 259ms mump2p publish --topic test/data --file ./payload.json ``` +### From stdin + +When stdin is a pipe or redirect, its contents are published. Non-empty stdin takes precedence over `--message`; an empty pipe falls back to `--message`. This makes `publish` work like any other Unix tool in a pipe: + +```bash +echo "Hello World" | mump2p publish --topic test +cat ./payload.json | mump2p publish --topic test/data +curl -s https://api.example.com/status | mump2p publish --topic status +``` + +`--file -` also reads from stdin explicitly. Stdin is capped at your account's maximum message size. + ## Debug Mode Use `--debug` to see session details, node scores, timing breakdowns, message IDs, and peer paths. diff --git a/cmd/publish.go b/cmd/publish.go index 16a1b60..5169f04 100644 --- a/cmd/publish.go +++ b/cmd/publish.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "os" "time" @@ -64,20 +65,127 @@ func shortMsgID(resp *pb.Response) string { return "" } +// payloadSource describes where the publish payload should come from, as +// derived from the command flags. +type payloadSource struct { + message string + messageSet bool // --message was given explicitly (possibly empty) + filePath string + fileSet bool // --file was given explicitly (possibly empty) +} + +// validatePayloadFlags rejects flag combinations that cannot be resolved. +func validatePayloadFlags(src payloadSource) error { + if src.messageSet && src.fileSet { + return errors.New("only one of --message or --file should be used at a time") + } + if src.fileSet && src.filePath == "" { + return errors.New("--file requires a path (use - for stdin)") + } + return nil +} + +// resolvePublishPayload returns the payload from --file, stdin, or --message. +// Stdin (a pipe or redirect, never an interactive terminal) takes precedence +// over --message when it carries data, as requested in #92; --file=- reads +// stdin explicitly. Reads are capped at maxBytes to bound memory use. +func resolvePublishPayload(src payloadSource, stdin *os.File, maxBytes int64) ([]byte, error) { + if err := validatePayloadFlags(src); err != nil { + return nil, err + } + + if src.fileSet && src.filePath != "-" { + content, err := os.ReadFile(src.filePath) + if err != nil { + return nil, fmt.Errorf("failed to read file: %v", err) + } + return content, nil + } + + stdinAvailable := src.filePath == "-" || !isTerminal(stdin) + if stdinAvailable { + content, err := readStdinBounded(stdin, maxBytes) + if err != nil { + return nil, err + } + if len(content) > 0 { + return content, nil + } + if src.filePath == "-" || !src.messageSet { + return nil, errors.New("stdin is empty: nothing to publish") + } + } + + if src.messageSet { + if src.message == "" { + return nil, errors.New("--message is empty: nothing to publish") + } + return []byte(src.message), nil + } + + return nil, errors.New("no message provided: use --message, --file, or pipe data via stdin") +} + +// readStdinBounded reads stdin to EOF, failing if it exceeds maxBytes. +func readStdinBounded(stdin *os.File, maxBytes int64) ([]byte, error) { + var r io.Reader = stdin + if maxBytes > 0 { + r = io.LimitReader(stdin, maxBytes+1) + } + content, err := io.ReadAll(r) + if err != nil { + return nil, fmt.Errorf("failed to read stdin: %v", err) + } + if maxBytes > 0 && int64(len(content)) > maxBytes { + return nil, fmt.Errorf("stdin exceeds the maximum message size of %d bytes", maxBytes) + } + return content, nil +} + +// isTerminal reports whether f is an interactive terminal. A nil or +// unreadable file counts as one so we never block on input that cannot arrive. +func isTerminal(f *os.File) bool { + if f == nil { + return true + } + info, err := f.Stat() + if err != nil { + return true + } + return info.Mode()&os.ModeCharDevice != 0 +} + var publishCmd = &cobra.Command{ Use: "publish", Short: "Publish a message to the Optimum Network", + Long: `Publish a message to the Optimum Network. + +The payload comes from --file, stdin, or --message. Piped stdin is used +whenever it carries data (it takes precedence over --message), so the +command composes in pipes: + + echo "hello" | mump2p publish --topic=test + curl -s https://api.example.com/status | mump2p publish --topic=status + +Use --file=- to read from stdin explicitly.`, + Example: ` mump2p publish --topic=test --message="Hello World" + mump2p publish --topic=test/data --file=./payload.json + cat payload.json | mump2p publish --topic=test/data`, RunE: func(cmd *cobra.Command, args []string) error { - if pubMessage == "" && file == "" { - return errors.New("either --message or --file must be provided") + src := payloadSource{ + message: pubMessage, + messageSet: cmd.Flags().Changed("message"), + filePath: file, + fileSet: cmd.Flags().Changed("file"), } - if pubMessage != "" && file != "" { - return errors.New("only one of --message or --file should be used at a time") + if err := validatePayloadFlags(src); err != nil { + return err } var claims *auth.TokenClaims var clientIDToUse string var accessToken string + maxMessageSize := int64(config.DefaultMaxMessageSize) if !IsAuthDisabled() { authClient := auth.NewClient() @@ -96,6 +204,9 @@ var publishCmd = &cobra.Command{ return fmt.Errorf("your account is inactive, please contact support") } clientIDToUse = claims.ClientID + if claims.MaxMessageSize > 0 { + maxMessageSize = claims.MaxMessageSize + } } else { clientIDToUse = GetClientID() if clientIDToUse == "" { @@ -103,16 +214,10 @@ var publishCmd = &cobra.Command{ } } - var data []byte - - if file != "" { - content, err := os.ReadFile(file) - if err != nil { - return fmt.Errorf("failed to read file: %v", err) - } - data = content - } else { - data = []byte(pubMessage) + // Read the payload only after the size limit is known so stdin is bounded. + data, err := resolvePublishPayload(src, os.Stdin, maxMessageSize) + if err != nil { + return err } messageSize := int64(len(data)) @@ -244,8 +349,8 @@ var publishCmd = &cobra.Command{ func init() { publishCmd.Flags().StringVar(&pubTopic, "topic", "", "Topic to publish to") - publishCmd.Flags().StringVar(&pubMessage, "message", "", "Message string to publish") - publishCmd.Flags().StringVar(&file, "file", "", "Path of the file to publish") + publishCmd.Flags().StringVar(&pubMessage, "message", "", "Message string to publish (reads stdin if neither --message nor --file is set)") + publishCmd.Flags().StringVar(&file, "file", "", "Path of the file to publish (use - for stdin)") publishCmd.Flags().StringVar(&serviceURL, "service-url", "", "Override the default proxy URL") publishCmd.Flags().Uint32Var(&pubExposeAmount, "expose-amount", 1, "Number of nodes to request from proxy") publishCmd.MarkFlagRequired("topic") //nolint:errcheck diff --git a/cmd/publish_test.go b/cmd/publish_test.go new file mode 100644 index 0000000..7ed44fc --- /dev/null +++ b/cmd/publish_test.go @@ -0,0 +1,204 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +const testMaxBytes = 1 << 20 + +// stdinFrom returns the read end of a pipe pre-filled with content, simulating piped stdin. +func stdinFrom(t *testing.T, content string) *os.File { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + if content != "" { + if _, err := w.WriteString(content); err != nil { + t.Fatalf("write to pipe: %v", err) + } + } + if err := w.Close(); err != nil { + t.Fatalf("close pipe writer: %v", err) + } + t.Cleanup(func() { r.Close() }) + return r +} + +// terminalStdin returns a char device (like an interactive terminal), or skips if unavailable. +func terminalStdin(t *testing.T) *os.File { + t.Helper() + f, err := os.Open(os.DevNull) + if err != nil { + t.Skipf("cannot open %s: %v", os.DevNull, err) + } + t.Cleanup(func() { f.Close() }) + if !isTerminal(f) { + t.Skipf("%s is not a char device on this platform", os.DevNull) + } + return f +} + +// messageSrc builds a payloadSource for an explicit --message. +func messageSrc(msg string) payloadSource { + return payloadSource{message: msg, messageSet: true} +} + +// fileSrc builds a payloadSource for an explicit --file. +func fileSrc(path string) payloadSource { + return payloadSource{filePath: path, fileSet: true} +} + +// TestResolvePublishPayload_MessageFlag checks --message is used when stdin is a terminal. +func TestResolvePublishPayload_MessageFlag(t *testing.T) { + got, err := resolvePublishPayload(messageSrc("hello"), terminalStdin(t), testMaxBytes) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(got) != "hello" { + t.Fatalf("want %q, got %q", "hello", got) + } +} + +// TestResolvePublishPayload_StdinOverridesMessage checks piped stdin wins over --message (#92). +func TestResolvePublishPayload_StdinOverridesMessage(t *testing.T) { + got, err := resolvePublishPayload(messageSrc("flag"), stdinFrom(t, "piped"), testMaxBytes) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(got) != "piped" { + t.Fatalf("want %q, got %q", "piped", got) + } +} + +// TestResolvePublishPayload_EmptyStdinFallsBackToMessage checks an empty pipe does not discard --message. +func TestResolvePublishPayload_EmptyStdinFallsBackToMessage(t *testing.T) { + got, err := resolvePublishPayload(messageSrc("flag"), stdinFrom(t, ""), testMaxBytes) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(got) != "flag" { + t.Fatalf("want %q, got %q", "flag", got) + } +} + +// TestResolvePublishPayload_ExplicitEmptyMessage checks an explicitly empty --message is an error, not a stdin fallback. +func TestResolvePublishPayload_ExplicitEmptyMessage(t *testing.T) { + _, err := resolvePublishPayload(messageSrc(""), terminalStdin(t), testMaxBytes) + if err == nil { + t.Fatal("expected error for explicit empty --message") + } + _, err = resolvePublishPayload(messageSrc(""), stdinFrom(t, ""), testMaxBytes) + if err == nil { + t.Fatal("expected error for explicit empty --message with empty stdin") + } +} + +// TestResolvePublishPayload_FileFlag checks --file contents are used as-is. +func TestResolvePublishPayload_FileFlag(t *testing.T) { + path := filepath.Join(t.TempDir(), "payload.json") + if err := os.WriteFile(path, []byte(`{"a":1}`), 0o600); err != nil { + t.Fatalf("write temp file: %v", err) + } + + got, err := resolvePublishPayload(fileSrc(path), stdinFrom(t, "ignored"), testMaxBytes) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(got) != `{"a":1}` { + t.Fatalf("want %q, got %q", `{"a":1}`, got) + } +} + +// TestResolvePublishPayload_FileMissing checks a missing --file path is reported. +func TestResolvePublishPayload_FileMissing(t *testing.T) { + _, err := resolvePublishPayload(fileSrc(filepath.Join(t.TempDir(), "nope")), stdinFrom(t, ""), testMaxBytes) + if err == nil { + t.Fatal("expected error for missing file") + } +} + +// TestValidatePayloadFlags checks rejected flag combinations, including empty explicit values. +func TestValidatePayloadFlags(t *testing.T) { + cases := []struct { + name string + src payloadSource + }{ + {"message and file", payloadSource{message: "m", messageSet: true, filePath: "f", fileSet: true}}, + {"empty message and file", payloadSource{message: "", messageSet: true, filePath: "f", fileSet: true}}, + {"empty file path", payloadSource{filePath: "", fileSet: true}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if err := validatePayloadFlags(tc.src); err == nil { + t.Fatal("expected validation error") + } + if _, err := resolvePublishPayload(tc.src, stdinFrom(t, "data"), testMaxBytes); err == nil { + t.Fatal("expected resolver to reject invalid flags") + } + }) + } + if err := validatePayloadFlags(messageSrc("ok")); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// TestResolvePublishPayload_StdinWhenNoFlags checks stdin is used when no payload flags are set. +func TestResolvePublishPayload_StdinWhenNoFlags(t *testing.T) { + got, err := resolvePublishPayload(payloadSource{}, stdinFrom(t, "from stdin\n"), testMaxBytes) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(got) != "from stdin\n" { + t.Fatalf("want %q, got %q", "from stdin\n", got) + } +} + +// TestResolvePublishPayload_FileDashReadsStdin checks --file=- reads stdin explicitly. +func TestResolvePublishPayload_FileDashReadsStdin(t *testing.T) { + got, err := resolvePublishPayload(fileSrc("-"), stdinFrom(t, "dash"), testMaxBytes) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(got) != "dash" { + t.Fatalf("want %q, got %q", "dash", got) + } +} + +// TestResolvePublishPayload_EmptyStdin checks empty stdin without a fallback is an error. +func TestResolvePublishPayload_EmptyStdin(t *testing.T) { + if _, err := resolvePublishPayload(payloadSource{}, stdinFrom(t, ""), testMaxBytes); err == nil { + t.Fatal("expected error for empty stdin") + } + if _, err := resolvePublishPayload(fileSrc("-"), stdinFrom(t, ""), testMaxBytes); err == nil { + t.Fatal("expected error for empty stdin with --file=-") + } +} + +// TestResolvePublishPayload_TerminalNoFlags checks the command fails fast instead of blocking on a terminal. +func TestResolvePublishPayload_TerminalNoFlags(t *testing.T) { + if _, err := resolvePublishPayload(payloadSource{}, terminalStdin(t), testMaxBytes); err == nil { + t.Fatal("expected error when no flags and stdin is a terminal") + } + if _, err := resolvePublishPayload(payloadSource{}, nil, testMaxBytes); err == nil { + t.Fatal("expected error when no flags and stdin is nil") + } +} + +// TestResolvePublishPayload_StdinSizeLimit checks stdin larger than maxBytes is rejected. +func TestResolvePublishPayload_StdinSizeLimit(t *testing.T) { + const limit = 16 + if _, err := resolvePublishPayload(payloadSource{}, stdinFrom(t, strings.Repeat("x", limit+1)), limit); err == nil { + t.Fatal("expected error for oversized stdin") + } + got, err := resolvePublishPayload(payloadSource{}, stdinFrom(t, strings.Repeat("x", limit)), limit) + if err != nil { + t.Fatalf("unexpected error at exact limit: %v", err) + } + if len(got) != limit { + t.Fatalf("want %d bytes, got %d", limit, len(got)) + } +} diff --git a/docs/guide.md b/docs/guide.md index 8c2316a..a2ea123 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -252,6 +252,17 @@ To publish the contents of a file: mump2p publish --topic=your-topic-name --file=/path/to/your/file.json ``` +### Publish from stdin + +When standard input is a pipe or redirect, its contents are published. Non-empty piped data takes precedence over `--message`; an empty pipe falls back to `--message` if given, otherwise the command errors. `--file=path` reads that file (it cannot be combined with `--message`), and `--file=-` selects stdin explicitly. This lets you pipe output from other commands straight into the network: + +```sh +echo "Your message content" | mump2p publish --topic=your-topic-name +curl -s https://api.example.com/status | mump2p publish --topic=status +``` + +You can also pass `--file=-` to read from stdin explicitly. Running `publish` with no payload flags in an interactive terminal (nothing piped in) returns an error instead of waiting for input, and stdin larger than your account's maximum message size is rejected without buffering the entire input. + Rate limits will be automatically applied based on your authentication token. ---