361 lines
8.9 KiB
Go
361 lines
8.9 KiB
Go
package subprocess
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
|
|
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
|
)
|
|
|
|
const (
|
|
// MaxStdoutDiagnosticBytes bounds persisted stdout from one external command.
|
|
MaxStdoutDiagnosticBytes int64 = 8 * 1024 * 1024
|
|
// MaxStderrDiagnosticBytes bounds persisted stderr from one external command.
|
|
MaxStderrDiagnosticBytes int64 = 8 * 1024 * 1024
|
|
)
|
|
|
|
var inheritedEnvironmentNames = map[string]struct{}{
|
|
"COMSPEC": {},
|
|
"HOME": {},
|
|
"PATH": {},
|
|
"SYSTEMROOT": {},
|
|
"TMP": {},
|
|
"TMPDIR": {},
|
|
"TEMP": {},
|
|
"WINDIR": {},
|
|
// These test-only helper destinations let the adapter package tests exercise
|
|
// real command invocation without widening the production environment.
|
|
"AUDITA_HELPER_RECORD_PATH": {},
|
|
"AUDITA_HELPER_MODE": {},
|
|
"GO_WANT_AUDITA_HELPER": {},
|
|
"GO_WANT_SCRIPTORIUM_HELPER": {},
|
|
"GO_WANT_SERIATIM_HELPER": {},
|
|
"GO_WANT_SUBPROCESS_HELPER": {},
|
|
"NOTARIUS_CAPTURE_DIR": {},
|
|
"NOTARIUS_RECEIPT_FIXTURE": {},
|
|
"SCRIPTORIUM_HELPER_RECORD_PATH": {},
|
|
"SCRIPTORIUM_HELPER_MODE": {},
|
|
"SERIATIM_HELPER_RECORD_PATH": {},
|
|
"SERIATIM_HELPER_MODE": {},
|
|
}
|
|
|
|
var sensitiveEnvironmentNames = map[string]struct{}{
|
|
"ANTHROPIC_API_KEY": {},
|
|
"API_KEY": {},
|
|
"AUDITA_LLM_API_KEY": {},
|
|
"AWS_ACCESS_KEY_ID": {},
|
|
"AWS_SECRET_ACCESS_KEY": {},
|
|
"AWS_SESSION_TOKEN": {},
|
|
"OPENAI_API_KEY": {},
|
|
"OPENROUTER_API_KEY": {},
|
|
}
|
|
|
|
type captureLimitError struct {
|
|
stream string
|
|
owner string
|
|
limit int64
|
|
}
|
|
|
|
func (e *captureLimitError) Error() string {
|
|
return fmt.Sprintf("%s diagnostic capture for %s exceeded %d bytes", e.stream, e.owner, e.limit)
|
|
}
|
|
|
|
type logWriters struct {
|
|
files []*os.File
|
|
Stdout io.Writer
|
|
Stderr io.Writer
|
|
|
|
limits chan *captureLimitError
|
|
mu sync.Mutex
|
|
limit *captureLimitError
|
|
stdout *diagnosticWriter
|
|
stderr *diagnosticWriter
|
|
}
|
|
|
|
type diagnosticWriter struct {
|
|
logs *logWriters
|
|
stream string
|
|
owner string
|
|
target io.Writer
|
|
limit int64
|
|
received int64
|
|
persisted int64
|
|
redactor streamRedactor
|
|
}
|
|
|
|
func openLogWriters(stdoutPath, stderrPath, owner string, sensitiveValues []string) (*logWriters, error) {
|
|
logs := &logWriters{limits: make(chan *captureLimitError, 1)}
|
|
cleanStdout := cleanLogPath(stdoutPath)
|
|
cleanStderr := cleanLogPath(stderrPath)
|
|
|
|
stdoutFile, err := openDiagnosticFile(cleanStdout)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open stdout log: %w", err)
|
|
}
|
|
stderrFile := stdoutFile
|
|
if cleanStdout != cleanStderr {
|
|
stderrFile, err = openDiagnosticFile(cleanStderr)
|
|
if err != nil {
|
|
_ = stdoutFile.Close()
|
|
return nil, fmt.Errorf("open stderr log: %w", err)
|
|
}
|
|
}
|
|
if cleanStdout == cleanStderr {
|
|
logs.files = []*os.File{stdoutFile}
|
|
} else {
|
|
logs.files = []*os.File{stdoutFile, stderrFile}
|
|
}
|
|
|
|
logs.stdout = newDiagnosticWriter(logs, "stdout", owner, stdoutFile, MaxStdoutDiagnosticBytes, sensitiveValues)
|
|
logs.stderr = newDiagnosticWriter(logs, "stderr", owner, stderrFile, MaxStderrDiagnosticBytes, sensitiveValues)
|
|
logs.Stdout = logs.stdout
|
|
logs.Stderr = logs.stderr
|
|
return logs, nil
|
|
}
|
|
|
|
func newDiagnosticWriter(logs *logWriters, stream, owner string, target io.Writer, limit int64, sensitiveValues []string) *diagnosticWriter {
|
|
return &diagnosticWriter{
|
|
logs: logs,
|
|
stream: stream,
|
|
owner: owner,
|
|
target: target,
|
|
limit: limit,
|
|
redactor: newStreamRedactor(sensitiveValues),
|
|
}
|
|
}
|
|
|
|
func (w *diagnosticWriter) Write(data []byte) (int, error) {
|
|
if w.received >= w.limit {
|
|
return len(data), w.reachLimit()
|
|
}
|
|
accepted := data
|
|
if remaining := w.limit - w.received; int64(len(accepted)) > remaining {
|
|
accepted = accepted[:remaining]
|
|
}
|
|
w.received += int64(len(accepted))
|
|
if err := w.writeRedacted(w.redactor.Write(accepted)); err != nil {
|
|
return len(data), err
|
|
}
|
|
if len(accepted) != len(data) {
|
|
return len(data), w.reachLimit()
|
|
}
|
|
return len(data), nil
|
|
}
|
|
|
|
func (w *diagnosticWriter) Flush() error {
|
|
return w.writeRedacted(w.redactor.Flush())
|
|
}
|
|
|
|
func (w *diagnosticWriter) writeRedacted(data []byte) error {
|
|
if len(data) == 0 {
|
|
return nil
|
|
}
|
|
w.logs.mu.Lock()
|
|
remaining := w.limit - w.persisted
|
|
if remaining <= 0 {
|
|
w.logs.mu.Unlock()
|
|
return w.reachLimit()
|
|
}
|
|
toWrite := data
|
|
exceeded := int64(len(data)) > remaining
|
|
if exceeded {
|
|
toWrite = toWrite[:remaining]
|
|
}
|
|
written, err := w.target.Write(toWrite)
|
|
w.persisted += int64(written)
|
|
w.logs.mu.Unlock()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if exceeded {
|
|
return w.reachLimit()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (w *diagnosticWriter) reachLimit() error {
|
|
limit := &captureLimitError{stream: w.stream, owner: w.owner, limit: w.limit}
|
|
w.logs.mu.Lock()
|
|
if w.logs.limit == nil {
|
|
w.logs.limit = limit
|
|
w.logs.limits <- limit
|
|
}
|
|
w.logs.mu.Unlock()
|
|
return limit
|
|
}
|
|
|
|
func (l *logWriters) Limits() <-chan *captureLimitError { return l.limits }
|
|
|
|
func (l *logWriters) Limit() *captureLimitError {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
return l.limit
|
|
}
|
|
|
|
func (l *logWriters) Flush() error {
|
|
return joinErrors(l.stdout.Flush(), l.stderr.Flush())
|
|
}
|
|
|
|
func (l *logWriters) Close() {
|
|
_ = l.Flush()
|
|
for _, file := range l.files {
|
|
_ = file.Close()
|
|
}
|
|
}
|
|
|
|
func cleanLogPath(path string) string {
|
|
trimmed := strings.TrimSpace(path)
|
|
if trimmed == "" {
|
|
return ""
|
|
}
|
|
return filepath.Clean(trimmed)
|
|
}
|
|
|
|
func openDiagnosticFile(path string) (*os.File, error) {
|
|
if path == "" {
|
|
return os.OpenFile(os.DevNull, os.O_WRONLY, 0)
|
|
}
|
|
if err := fileops.EnsureWorkspaceDirectory(filepath.Dir(path)); err != nil {
|
|
return nil, fmt.Errorf("create log directory for %q: %w", path, err)
|
|
}
|
|
file, err := os.Create(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open log file %q: %w", path, err)
|
|
}
|
|
if err := file.Chmod(fileops.WorkspaceFileMode); err != nil {
|
|
_ = file.Close()
|
|
return nil, fmt.Errorf("set log file permissions %q: %w", path, err)
|
|
}
|
|
return file, nil
|
|
}
|
|
|
|
func (r RunRequest) diagnosticOwner() string {
|
|
if owner := strings.TrimSpace(r.DiagnosticOwner); owner != "" {
|
|
return owner
|
|
}
|
|
return "subprocess"
|
|
}
|
|
|
|
func buildChildEnvironment(base []string, overrides map[string]string) []string {
|
|
values := make(map[string]string, len(inheritedEnvironmentNames)+len(overrides))
|
|
for _, item := range base {
|
|
name, value, ok := strings.Cut(item, "=")
|
|
if !ok {
|
|
continue
|
|
}
|
|
normalized := strings.ToUpper(name)
|
|
if _, allowed := inheritedEnvironmentNames[normalized]; allowed {
|
|
values[name] = value
|
|
}
|
|
}
|
|
for name, value := range overrides {
|
|
values[name] = value
|
|
}
|
|
names := make([]string, 0, len(values))
|
|
for name := range values {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
out := make([]string, 0, len(names))
|
|
for _, name := range names {
|
|
out = append(out, name+"="+values[name])
|
|
}
|
|
return out
|
|
}
|
|
|
|
func sensitiveEnvironmentValues(environment []string, additionalNames []string) []string {
|
|
names := make(map[string]struct{}, len(sensitiveEnvironmentNames)+len(additionalNames))
|
|
for name := range sensitiveEnvironmentNames {
|
|
names[name] = struct{}{}
|
|
}
|
|
for _, name := range additionalNames {
|
|
if trimmed := strings.ToUpper(strings.TrimSpace(name)); trimmed != "" {
|
|
names[trimmed] = struct{}{}
|
|
}
|
|
}
|
|
values := make([]string, 0, len(names))
|
|
for _, item := range environment {
|
|
name, value, ok := strings.Cut(item, "=")
|
|
if !ok || strings.TrimSpace(value) == "" {
|
|
continue
|
|
}
|
|
if _, sensitive := names[strings.ToUpper(name)]; sensitive {
|
|
values = append(values, value)
|
|
}
|
|
}
|
|
return values
|
|
}
|
|
|
|
type streamRedactor struct {
|
|
values []string
|
|
buffer []byte
|
|
maxLen int
|
|
}
|
|
|
|
func newStreamRedactor(values []string) streamRedactor {
|
|
unique := make(map[string]struct{}, len(values))
|
|
for _, value := range values {
|
|
if value != "" {
|
|
unique[value] = struct{}{}
|
|
}
|
|
}
|
|
sorted := make([]string, 0, len(unique))
|
|
for value := range unique {
|
|
sorted = append(sorted, value)
|
|
}
|
|
sort.Slice(sorted, func(i, j int) bool { return len(sorted[i]) > len(sorted[j]) })
|
|
maxLen := 1
|
|
for _, value := range sorted {
|
|
if len(value) > maxLen {
|
|
maxLen = len(value)
|
|
}
|
|
}
|
|
return streamRedactor{values: sorted, maxLen: maxLen}
|
|
}
|
|
|
|
func (r *streamRedactor) Write(data []byte) []byte {
|
|
r.buffer = append(r.buffer, data...)
|
|
safeCut := len(r.buffer) - r.maxLen + 1
|
|
if safeCut <= 0 {
|
|
return nil
|
|
}
|
|
emitCut := safeCut
|
|
for _, value := range r.values {
|
|
start := 0
|
|
for {
|
|
index := bytes.Index(r.buffer[start:], []byte(value))
|
|
if index < 0 {
|
|
break
|
|
}
|
|
index += start
|
|
if index+len(value) > safeCut && index < emitCut {
|
|
emitCut = index
|
|
}
|
|
start = index + 1
|
|
}
|
|
}
|
|
output := redactBytes(r.buffer[:emitCut], r.values)
|
|
r.buffer = append(r.buffer[:0], r.buffer[emitCut:]...)
|
|
return output
|
|
}
|
|
|
|
func (r *streamRedactor) Flush() []byte {
|
|
output := redactBytes(r.buffer, r.values)
|
|
r.buffer = nil
|
|
return output
|
|
}
|
|
|
|
func redactBytes(data []byte, values []string) []byte {
|
|
out := append([]byte(nil), data...)
|
|
for _, value := range values {
|
|
out = bytes.ReplaceAll(out, []byte(value), []byte("<redacted>"))
|
|
}
|
|
return out
|
|
}
|