178 lines
5.9 KiB
Go
178 lines
5.9 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package consume
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
|
)
|
|
|
|
// Sink is what a Pipeline writes formatted event bytes to. Implementations
|
|
// take both the event (for filename derivation in file sinks) and the
|
|
// already-formatted bytes (so the same event can be rendered with
|
|
// different formats per call without re-running the formatter inside the
|
|
// sink).
|
|
type Sink interface {
|
|
// Write places one event on the sink. Returns an error for IO failures;
|
|
// returns ErrPipeClosed when the downstream consumer closed the pipe
|
|
// (typical pattern: `dws event consume | head -1`).
|
|
Write(ev transport.Event, formatted []byte) error
|
|
// Close releases sink-owned resources. Idempotent.
|
|
Close() error
|
|
}
|
|
|
|
// ErrPipeClosed is returned by stdout-style sinks when the downstream
|
|
// reader closed its end (SIGPIPE / EPIPE on Unix, ERROR_BROKEN_PIPE on
|
|
// Windows). The pipeline catches this sentinel and exits cleanly without
|
|
// surfacing it as a fatal error.
|
|
var ErrPipeClosed = errors.New("sink: downstream pipe closed")
|
|
|
|
var (
|
|
mkdirSinkDir = os.MkdirAll
|
|
writeSinkFile = os.WriteFile
|
|
renameSinkFile = os.Rename
|
|
removeSinkFile = os.Remove
|
|
)
|
|
|
|
// NewStdoutSink wraps the given writer (typically os.Stdout) in a Sink
|
|
// that writes formatted bytes verbatim. Detects broken-pipe on the host
|
|
// platform and returns ErrPipeClosed so the caller can exit code 0.
|
|
func NewStdoutSink(w io.Writer) Sink { return &stdoutSink{w: w} }
|
|
|
|
type stdoutSink struct{ w io.Writer }
|
|
|
|
func (s *stdoutSink) Write(_ transport.Event, formatted []byte) error {
|
|
_, err := s.w.Write(formatted)
|
|
if err != nil && isBrokenPipe(err) {
|
|
return ErrPipeClosed
|
|
}
|
|
return err
|
|
}
|
|
func (s *stdoutSink) Close() error { return nil }
|
|
|
|
// NewFileDirSink returns a sink that writes each event to its own file
|
|
// under dir, naming files `{type}_{id}_{ts}.json`. The directory is
|
|
// mkdir'd on first write so callers don't have to ensure it themselves.
|
|
//
|
|
// Filename pieces are sanitised: characters that would escape the
|
|
// directory (path separators) or break shell globbing are replaced with
|
|
// '_'. `ts` is the ReceivedAtUnixMS (or current time if zero) so two
|
|
// events with the same id (re-delivery, dedup-defeated edge cases) don't
|
|
// collide.
|
|
func NewFileDirSink(dir string) Sink { return &fileDirSink{dir: dir} }
|
|
|
|
type fileDirSink struct{ dir string }
|
|
|
|
func (s *fileDirSink) Write(ev transport.Event, formatted []byte) error {
|
|
if err := mkdirSinkDir(s.dir, 0o700); err != nil {
|
|
return fmt.Errorf("sink: mkdir %s: %w", s.dir, err)
|
|
}
|
|
name := buildFilename(ev)
|
|
full := filepath.Join(s.dir, name)
|
|
return atomicWrite(full, formatted)
|
|
}
|
|
func (s *fileDirSink) Close() error { return nil }
|
|
|
|
// NewRoutedSink composes a Router with per-route dir sinks plus a fallback.
|
|
// On each Write, Router.Match decides the target dir; if non-empty, the
|
|
// event is written there; otherwise the fallback sink handles it. The
|
|
// fallback is typically NewStdoutSink (default) or NewFileDirSink
|
|
// (--output-dir mode).
|
|
func NewRoutedSink(router *Router, fallback Sink) Sink {
|
|
return &routedSink{router: router, fallback: fallback}
|
|
}
|
|
|
|
type routedSink struct {
|
|
router *Router
|
|
fallback Sink
|
|
}
|
|
|
|
func (s *routedSink) Write(ev transport.Event, formatted []byte) error {
|
|
if dir := s.router.Match(ev); dir != "" {
|
|
return NewFileDirSink(dir).Write(ev, formatted)
|
|
}
|
|
return s.fallback.Write(ev, formatted)
|
|
}
|
|
func (s *routedSink) Close() error { return s.fallback.Close() }
|
|
|
|
// buildFilename produces `{type}_{id}_{ts}.json`. All three pieces are
|
|
// sanitised to be safe filesystem path segments — see safePart.
|
|
func buildFilename(ev transport.Event) string {
|
|
typ := safePart(ev.EventType)
|
|
if typ == "" {
|
|
typ = "unknown"
|
|
}
|
|
id := safePart(ev.EventID)
|
|
if id == "" {
|
|
id = "no-id"
|
|
}
|
|
ts := ev.ReceivedAtUnixMS
|
|
if ts == 0 {
|
|
ts = time.Now().UTC().UnixMilli()
|
|
}
|
|
return fmt.Sprintf("%s_%s_%d.json", typ, id, ts)
|
|
}
|
|
|
|
// safePart strips path separators, NULs, and leading/trailing whitespace
|
|
// from a filename piece. Replaces unsafe chars with '_' instead of
|
|
// dropping them so different inputs don't collide.
|
|
//
|
|
// We DO allow dots and dashes (common in event types like "im.message.at_v1");
|
|
// we just reject path separators and parent-directory traversal.
|
|
func safePart(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
var b strings.Builder
|
|
b.Grow(len(s))
|
|
for _, r := range s {
|
|
switch r {
|
|
case '/', '\\', 0, ':':
|
|
b.WriteRune('_')
|
|
default:
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
return b.String()
|
|
// We intentionally do NOT collapse ".." sequences. After path
|
|
// separator replacement (above), a bare ".." in the middle of a
|
|
// filename cannot perform parent-directory traversal because there
|
|
// is no separator to anchor it against. The final filename is
|
|
// joined into a known-safe directory with filepath.Join which itself
|
|
// will clean any traversal that does sneak through.
|
|
}
|
|
|
|
// atomicWrite writes content to path via tmp-file + rename, so a concurrent
|
|
// reader either sees the previous version or the new version — never a
|
|
// half-written file.
|
|
func atomicWrite(path string, content []byte) error {
|
|
tmp := path + ".tmp"
|
|
if err := writeSinkFile(tmp, content, 0o600); err != nil {
|
|
return fmt.Errorf("sink: write tmp %s: %w", tmp, err)
|
|
}
|
|
if err := renameSinkFile(tmp, path); err != nil {
|
|
_ = removeSinkFile(tmp)
|
|
return fmt.Errorf("sink: rename to %s: %w", path, err)
|
|
}
|
|
return nil
|
|
}
|