package eventstream import ( "encoding/csv" "errors" "fmt" "os" "os/exec" "path/filepath" "strings" "time" ) // shellSplit tokenizes s using POSIX-like shell quoting rules so that paths // containing spaces (e.g. EDITOR='/My Editor/hx') are preserved as a single // token. It supports: // - single-quoted strings : no escape processing inside ' … ' // - double-quoted strings : \" and \\ are recognised; other backslashes // are kept verbatim // - unquoted tokens : backslash escapes the next character // // Unterminated quotes are treated as if the closing delimiter is implicit at // end-of-string, matching common shell lenient behaviour. func shellSplit(s string) []string { var tokens []string var current strings.Builder inToken := false i := 0 for i < len(s) { ch := s[i] switch { case ch == '\'': inToken = true i = consumeSingleQuoted(s, i+1, ¤t) case ch == '"': inToken = true i = consumeDoubleQuoted(s, i+1, ¤t) case ch == '\\': inToken = true i = consumeBackslash(s, i, ¤t) case ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r': // Whitespace: flush current token if any. if inToken { tokens = append(tokens, current.String()) current.Reset() inToken = false } i++ default: inToken = true current.WriteByte(ch) i++ } } if inToken { tokens = append(tokens, current.String()) } return tokens } // consumeSingleQuoted copies characters verbatim from s starting at i until // the closing single-quote (or end-of-string). Returns the index after the // closing quote. func consumeSingleQuoted(s string, i int, out *strings.Builder) int { for i < len(s) && s[i] != '\'' { out.WriteByte(s[i]) i++ } if i < len(s) { i++ // consume the closing ' } return i } // consumeDoubleQuoted copies characters from s starting at i until the // closing double-quote, processing \" and \\ escape sequences. Returns the // index after the closing quote. func consumeDoubleQuoted(s string, i int, out *strings.Builder) int { for i < len(s) && s[i] != '"' { if s[i] == '\\' && i+1 < len(s) { next := s[i+1] if next == '"' || next == '\\' { out.WriteByte(next) i += 2 continue } } out.WriteByte(s[i]) i++ } if i < len(s) { i++ // consume the closing " } return i } // consumeBackslash handles a backslash outside any quoted context: if a next // character exists it is treated as escaped; a trailing backslash is kept as-is. // i must point at the backslash character. Returns the index after consumed bytes. func consumeBackslash(s string, i int, out *strings.Builder) int { if i+1 < len(s) { out.WriteByte(s[i+1]) return i + 2 } // Trailing backslash: keep it. out.WriteByte('\\') return i + 1 } func defaultStreamExportFilename() string { return fmt.Sprintf("ior-stream-%s.csv", time.Now().Format("20060102-150405")) } func exportSnapshotToCSV(source Source, filter Filter, exportDir, filename string) (string, error) { name := strings.TrimSpace(filename) if name == "" { name = defaultStreamExportFilename() } rows := make([]StreamEvent, 0) if source != nil { snapshot := source.Snapshot() rows = make([]StreamEvent, 0, len(snapshot)) for i := range snapshot { ev := snapshot[i] if filter.Matches(&ev) { rows = append(rows, ev) } } } return exportRowsToCSV(rows, exportDir, name) } // exportRowsToCSV writes rows to a CSV file under exportDir with the given // filename (which is validated and sanitised by ensureCSVFilename). func exportRowsToCSV(rows []StreamEvent, exportDir, filename string) (string, error) { name, err := ensureCSVFilename(filename) if err != nil { return "", err } path := name if exportDir != "" { path = filepath.Join(exportDir, name) } f, err := os.Create(path) if err != nil { return "", err } // closeFile is idempotent; fail wraps any write error with a best-effort close. closed := false closeFile := func() error { if closed { return nil } closed = true return f.Close() } fail := func(baseErr error) (string, error) { if closeErr := closeFile(); closeErr != nil { return "", errors.Join(baseErr, closeErr) } return "", baseErr } if err := writeStreamCSV(csv.NewWriter(f), rows, fail); err != nil { return "", err } if err := closeFile(); err != nil { return "", err } absPath, err := filepath.Abs(path) if err != nil { return path, nil } return absPath, nil } // writeStreamCSV writes the CSV header and all event rows to w, calling fail // on the first write error to close the underlying file before returning. func writeStreamCSV(w *csv.Writer, rows []StreamEvent, fail func(error) (string, error)) error { header := []string{"seq", "time_ns", "gap_ns", "latency_ns", "comm", "pid", "tid", "syscall", "fd", "ret", "bytes", "file", "error", "family", "requested_sleep_ns"} if err := w.Write(header); err != nil { _, err = fail(err) return err } for i := range rows { ev := rows[i] record := []string{ fmt.Sprintf("%d", ev.Seq), fmt.Sprintf("%d", ev.TimeNs), fmt.Sprintf("%d", ev.GapNs), fmt.Sprintf("%d", ev.DurationNs), ev.Comm, fmt.Sprintf("%d", ev.PID), fmt.Sprintf("%d", ev.TID), ev.Syscall, fmt.Sprintf("%d", ev.FD), fmt.Sprintf("%d", ev.RetVal), fmt.Sprintf("%d", ev.Bytes), ev.FileName, fmt.Sprintf("%t", ev.IsError), ev.Family, fmt.Sprintf("%d", ev.RequestedSleepNs), } if err := w.Write(record); err != nil { _, err = fail(err) return err } } w.Flush() if err := w.Error(); err != nil { _, err = fail(err) return err } return nil } // ensureCSVFilename validates and normalises a user-supplied export filename. // It strips any directory components (preventing path traversal outside // exportDir) and rejects names that resolve to "." or "..". A ".csv" // extension is appended when the caller omits it. func ensureCSVFilename(name string) (string, error) { clean := strings.TrimSpace(name) if clean == "" { return "", errors.New("filename cannot be empty") } // Strip all directory components so that inputs such as // "../../etc/passwd" or "/absolute/path.csv" cannot escape exportDir. base := filepath.Base(clean) // filepath.Base returns "." for empty/dot inputs and ".." for a raw ".." // component — both are unusable as a plain filename. if base == "." || base == ".." { return "", errors.New("filename must not be a directory reference") } if strings.HasSuffix(strings.ToLower(base), ".csv") { return base, nil } return base + ".csv", nil } // ExportSnapshotToCSV exports a fresh filtered snapshot from the current source // without mutating the model's paused/live view state. func (m Model) ExportSnapshotToCSV(filename string) (string, error) { return exportSnapshotToCSV(m.source, m.filter, m.exportDir, filename) } func (m *Model) exportFilteredToCSV(filename string) (string, error) { return exportRowsToCSV(m.filtered, m.exportDir, filename) } // EditorCommandForPath builds an editor command for the given path. func EditorCommandForPath(path string) (*exec.Cmd, error) { parts, _, err := resolveEditorCommand() if err != nil { return nil, err } args := append(parts[1:], path) return exec.Command(parts[0], args...), nil } func resolveEditorCommand() ([]string, string, error) { candidates := []string{"EDITOR", "VISUAL", "SUDO_EDITOR"} for _, key := range candidates { value := strings.TrimSpace(os.Getenv(key)) if value == "" { continue } // Use shellSplit instead of strings.Fields so that quoted paths with // spaces (e.g. EDITOR='/My Editor/hx') are not broken into multiple // tokens. parts := shellSplit(value) if len(parts) == 0 { continue } return parts, key, nil } return []string{fallbackEditor()}, "fallback", nil } func fallbackEditor() string { if _, err := exec.LookPath("hx"); err == nil { return "hx" } return "vi" }