diff --git a/tools/cmd/exec/exec.go b/tools/cmd/exec/exec.go new file mode 100644 index 0000000..a9b1570 --- /dev/null +++ b/tools/cmd/exec/exec.go @@ -0,0 +1,155 @@ +package main + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +type execInput struct { + Cmd string `json:"cmd"` + Args []string `json:"args"` + Cwd string `json:"cwd,omitempty"` + Env map[string]string `json:"env,omitempty"` + Stdin string `json:"stdin,omitempty"` + TimeoutSec int `json:"timeoutSec,omitempty"` +} + +type execOutput struct { + ExitCode int `json:"exitCode"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + DurationMs int64 `json:"durationMs"` +} + +func main() { + in, err := readInput(os.Stdin) + if err != nil { + // Standardized error contract: write single-line JSON to stderr and exit non-zero + msg := sanitizeError(err) + fmt.Fprintf(os.Stderr, "{\"error\":%q}\n", msg) + os.Exit(1) + } + + stdout, stderr, exitCode, dur := runCommand(in) + writeOutput(execOutput{ExitCode: exitCode, Stdout: stdout, Stderr: stderr, DurationMs: dur}) +} + +func readInput(r io.Reader) (execInput, error) { + var in execInput + br := bufio.NewReader(r) + data, err := io.ReadAll(br) + if err != nil { + return in, fmt.Errorf("read stdin: %w", err) + } + if err := json.Unmarshal(data, &in); err != nil { + return in, fmt.Errorf("parse json: %w", err) + } + if strings.TrimSpace(in.Cmd) == "" { + return in, fmt.Errorf("cmd is required") + } + return in, nil +} + +func runCommand(in execInput) (stdoutStr, stderrStr string, exitCode int, durationMs int64) { + start := time.Now() + ctx := context.Background() + var cancel context.CancelFunc + if in.TimeoutSec > 0 { + ctx, cancel = context.WithTimeout(ctx, time.Duration(in.TimeoutSec)*time.Second) + defer cancel() + } + + cmd := exec.CommandContext(ctx, in.Cmd, in.Args...) + if strings.TrimSpace(in.Cwd) != "" { + // Ensure cwd is clean and absolute if provided as relative + if !filepath.IsAbs(in.Cwd) { + if abs, err := filepath.Abs(in.Cwd); err == nil { + cmd.Dir = abs + } else { + // Fall back to provided value if Abs fails + cmd.Dir = in.Cwd + } + } else { + cmd.Dir = in.Cwd + } + } + // Start from current environment and apply overrides + env := os.Environ() + for k, v := range in.Env { + if strings.Contains(k, "=") { + // Skip invalid keys defensively + continue + } + env = append(env, fmt.Sprintf("%s=%s", k, v)) + } + cmd.Env = env + + if in.Stdin != "" { + cmd.Stdin = strings.NewReader(in.Stdin) + } + var stdoutBuf, stderrBuf strings.Builder + cmd.Stdout = &stdoutBuf + cmd.Stderr = &stderrBuf + + exitCode = 0 + err := cmd.Run() + durationMs = time.Since(start).Milliseconds() + + stdoutStr = stdoutBuf.String() + stderrStr = stderrBuf.String() + + if err == nil { + return + } + // Determine exit code and normalize timeout message + if ctxErr := ctx.Err(); ctxErr == context.DeadlineExceeded { + // Timed out + if ee, ok := err.(*exec.ExitError); ok { + exitCode = ee.ExitCode() + } else { + exitCode = 1 + } + if !strings.Contains(strings.ToLower(stderrStr), "timeout") { + if len(stderrStr) > 0 && !strings.HasSuffix(stderrStr, "\n") { + stderrStr += "\n" + } + stderrStr += "timeout" + } + return + } + if ee, ok := err.(*exec.ExitError); ok { + exitCode = ee.ExitCode() + } else { + exitCode = 1 + } + return +} + +func writeOutput(out execOutput) { + enc, err := json.Marshal(out) + if err != nil { + // Best-effort: emit minimal JSON + fmt.Println("{\"exitCode\":0,\"stdout\":\"\",\"stderr\":\"marshal error\",\"durationMs\":0}") + return + } + // Single line JSON + fmt.Println(string(enc)) +} + +func sanitizeError(err error) string { + if err == nil { + return "" + } + msg := err.Error() + // Collapse newlines to keep single-line contract + msg = strings.ReplaceAll(msg, "\n", " ") + return msg +} diff --git a/tools/cmd/exec/exec_test.go b/tools/cmd/exec/exec_test.go new file mode 100644 index 0000000..413bb15 --- /dev/null +++ b/tools/cmd/exec/exec_test.go @@ -0,0 +1,177 @@ +package main_test + +import ( + "bytes" + "encoding/json" + "os/exec" + "runtime" + "strings" + "testing" + + testutil "github.com/hyperifyio/goagent/tools/testutil" +) + +// execOutput models the expected stdout JSON contract from tools/exec.go +type execOutput struct { + ExitCode int `json:"exitCode"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + DurationMs int64 `json:"durationMs"` +} + +// runExec runs the built exec tool with the given JSON input and decodes stdout. +func runExec(t *testing.T, bin string, input any) execOutput { + t.Helper() + data, err := json.Marshal(input) + if err != nil { + t.Fatalf("marshal input: %v", err) + } + cmd := exec.Command(bin) + cmd.Stdin = bytes.NewReader(data) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + t.Fatalf("exec tool failed to run: %v, stderr=%s", err, stderr.String()) + } + // Output must be single-line JSON + out := strings.TrimSpace(stdout.String()) + var parsed execOutput + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("failed to parse exec output JSON: %v; raw=%q", err, out) + } + if parsed.DurationMs < 0 { + t.Fatalf("durationMs must be >= 0, got %d", parsed.DurationMs) + } + return parsed +} + +// TestExec_InvalidJSON verifies stderr JSON error contract and non-zero exit +func TestExec_InvalidJSON(t *testing.T) { + bin := testutil.BuildTool(t, "exec") + // Run with invalid JSON (not an object) + cmd := exec.Command(bin) + cmd.Stdin = strings.NewReader("not-json") + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + if err == nil { + t.Fatalf("expected non-zero exit for invalid JSON") + } + // Stderr must be single-line JSON: {"error":"..."} + line := strings.TrimSpace(stderr.String()) + if line == "" || !strings.HasPrefix(line, "{") || !strings.HasSuffix(line, "}") || strings.Contains(line, "\n") { + t.Fatalf("stderr not single-line JSON: %q", line) + } + var payload map[string]any + if err := json.Unmarshal([]byte(line), &payload); err != nil { + t.Fatalf("stderr not JSON parseable: %v raw=%q", err, line) + } + if _, ok := payload["error"]; !ok { + t.Fatalf("stderr JSON missing 'error' field: %v", payload) + } +} + +func TestExec_SuccessEcho(t *testing.T) { + bin := testutil.BuildTool(t, "exec") + // Use /bin/echo on Unix; on Windows, use cmd /c echo via a small program is complex. + if runtime.GOOS == "windows" { + t.Skip("windows not supported in this test environment") + } + out := runExec(t, bin, map[string]any{ + "cmd": "/bin/echo", + "args": []string{"hello"}, + }) + if out.ExitCode != 0 { + t.Fatalf("expected exitCode 0, got %d (stderr=%q)", out.ExitCode, out.Stderr) + } + if strings.TrimSpace(out.Stdout) != "hello" { + t.Fatalf("unexpected stdout: %q", out.Stdout) + } +} + +func TestExec_NonZeroExit(t *testing.T) { + bin := testutil.BuildTool(t, "exec") + if runtime.GOOS == "windows" { + t.Skip("windows not supported in this test environment") + } + // /bin/false exits with code 1 + out := runExec(t, bin, map[string]any{ + "cmd": "/bin/false", + "args": []string{}, + }) + if out.ExitCode == 0 { + t.Fatalf("expected non-zero exitCode, got 0") + } + if out.Stdout != "" { + t.Fatalf("expected empty stdout for /bin/false, got %q", out.Stdout) + } +} + +func TestExec_Timeout(t *testing.T) { + bin := testutil.BuildTool(t, "exec") + if runtime.GOOS == "windows" { + t.Skip("windows not supported in this test environment") + } + out := runExec(t, bin, map[string]any{ + "cmd": "/bin/sleep", + "args": []string{"2"}, + "timeoutSec": 1, + }) + if out.ExitCode == 0 { + t.Fatalf("expected timeout to produce non-zero exitCode, got 0") + } + if !strings.Contains(strings.ToLower(out.Stderr), "timeout") { + t.Fatalf("stderr should mention timeout, got %q", out.Stderr) + } + if out.DurationMs < 900 || out.DurationMs > 3000 { + t.Fatalf("durationMs out of expected range: %d", out.DurationMs) + } +} + +func TestExec_CwdAndEnv(t *testing.T) { + bin := testutil.BuildTool(t, "exec") + if runtime.GOOS == "windows" { + t.Skip("windows not supported in this test environment") + } + tmpDir := t.TempDir() + out := runExec(t, bin, map[string]any{ + "cmd": "/bin/pwd", + "args": []string{}, + "cwd": tmpDir, + "env": map[string]string{ + "FOO": "BAR", + }, + }) + if strings.TrimSpace(out.Stdout) != tmpDir { + t.Fatalf("pwd did not respect cwd: expected %q, got %q", tmpDir, out.Stdout) + } + + // Now verify env via /usr/bin/env + out2 := runExec(t, bin, map[string]any{ + "cmd": "/usr/bin/env", + "args": []string{}, + "env": map[string]string{ + "HELLO": "WORLD", + }, + }) + if !strings.Contains(out2.Stdout, "HELLO=WORLD") { + t.Fatalf("env var not present in stdout: %q", out2.Stdout) + } +} + +func TestExec_StdinPassthrough(t *testing.T) { + bin := testutil.BuildTool(t, "exec") + if runtime.GOOS == "windows" { + t.Skip("windows not supported in this test environment") + } + out := runExec(t, bin, map[string]any{ + "cmd": "/bin/cat", + "args": []string{}, + "stdin": "xyz", + }) + if out.Stdout != "xyz" { + t.Fatalf("stdin passthrough failed, got %q", out.Stdout) + } +} diff --git a/tools/cmd/fs_append_file/fs_append_file.go b/tools/cmd/fs_append_file/fs_append_file.go new file mode 100644 index 0000000..e288d68 --- /dev/null +++ b/tools/cmd/fs_append_file/fs_append_file.go @@ -0,0 +1,106 @@ +package main + +import ( + "bufio" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" +) + +type appendInput struct { + Path string `json:"path"` + ContentBase64 string `json:"contentBase64"` +} + +type appendOutput struct { + BytesAppended int `json:"bytesAppended"` +} + +var fileLocks sync.Map // map[string]*sync.Mutex + +func main() { + in, err := readInput(os.Stdin) + if err != nil { + stderrJSON(err) + os.Exit(1) + } + if err := validatePath(in.Path); err != nil { + stderrJSON(err) + os.Exit(1) + } + data, err := base64.StdEncoding.DecodeString(in.ContentBase64) + if err != nil { + stderrJSON(fmt.Errorf("decode base64: %w", err)) + os.Exit(1) + } + // advisory lock per-path + muIface, _ := fileLocks.LoadOrStore(in.Path, &sync.Mutex{}) + mu, ok := muIface.(*sync.Mutex) + if !ok { + stderrJSON(errors.New("internal: invalid lock type")) + os.Exit(1) + } + mu.Lock() + defer mu.Unlock() + + f, err := os.OpenFile(in.Path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + stderrJSON(err) + os.Exit(1) + } + defer func() { + if cerr := f.Close(); cerr != nil { + // best-effort report; do not change exit code after success path + fmt.Fprintf(os.Stderr, "{\"error\":%q}\n", "close: "+strings.ReplaceAll(cerr.Error(), "\n", " ")) + } + }() + if _, err := f.Write(data); err != nil { + stderrJSON(err) + os.Exit(1) + } + if err := json.NewEncoder(os.Stdout).Encode(appendOutput{BytesAppended: len(data)}); err != nil { + stderrJSON(fmt.Errorf("write stdout: %w", err)) + os.Exit(1) + } +} + +func readInput(r io.Reader) (appendInput, error) { + var in appendInput + b, err := io.ReadAll(bufio.NewReader(r)) + if err != nil { + return in, fmt.Errorf("read stdin: %w", err) + } + if err := json.Unmarshal(b, &in); err != nil { + return in, fmt.Errorf("parse json: %w", err) + } + if strings.TrimSpace(in.Path) == "" { + return in, errors.New("path is required") + } + if strings.TrimSpace(in.ContentBase64) == "" { + return in, errors.New("contentBase64 is required") + } + return in, nil +} + +func validatePath(p string) error { + if filepath.IsAbs(p) { + return fmt.Errorf("path must be relative to repository root: %s", p) + } + clean := filepath.ToSlash(filepath.Clean(p)) + if strings.HasPrefix(clean, "../") || strings.Contains(clean, "/../") { + return fmt.Errorf("path escapes repository root: %s", p) + } + return nil +} + +func stderrJSON(err error) { + msg := err.Error() + msg = strings.ReplaceAll(msg, "\n", " ") + fmt.Fprintf(os.Stderr, "{\"error\":%q}\n", msg) +} diff --git a/tools/cmd/fs_append_file/fs_append_file_test.go b/tools/cmd/fs_append_file/fs_append_file_test.go new file mode 100644 index 0000000..a2f3599 --- /dev/null +++ b/tools/cmd/fs_append_file/fs_append_file_test.go @@ -0,0 +1,264 @@ +package main + +// https://github.com/hyperifyio/goagent/issues/1 + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + + testutil "github.com/hyperifyio/goagent/tools/testutil" +) + +type fsAppendOutput struct { + BytesAppended int `json:"bytesAppended"` +} + +// runFsAppend runs the built fs_append_file tool with the given JSON input. +func runFsAppend(t *testing.T, bin string, input any) (fsAppendOutput, string, int) { + t.Helper() + data, err := json.Marshal(input) + if err != nil { + t.Fatalf("marshal input: %v", err) + } + cmd := exec.Command(bin) + cmd.Dir = "." + cmd.Stdin = bytes.NewReader(data) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err = cmd.Run() + code := 0 + if err != nil { + if ee, ok := err.(*exec.ExitError); ok { + code = ee.ExitCode() + } else { + code = 1 + } + } + var out fsAppendOutput + if code == 0 { + if err := json.Unmarshal([]byte(strings.TrimSpace(stdout.String())), &out); err != nil { + t.Fatalf("unmarshal stdout: %v; raw=%q", err, stdout.String()) + } + } + return out, stderr.String(), code +} + +func TestFsAppend_DoubleAppend(t *testing.T) { + bin := testutil.BuildTool(t, "fs_append_file") + + dir := testutil.MakeRepoRelTempDir(t, "fsappend-double-") + path := filepath.Join(dir, "hello.txt") + + part1 := []byte("hello") + out1, stderr1, code1 := runFsAppend(t, bin, map[string]any{ + "path": path, + "contentBase64": base64.StdEncoding.EncodeToString(part1), + }) + if code1 != 0 { + t.Fatalf("first append expected success, got exit=%d stderr=%q", code1, stderr1) + } + if out1.BytesAppended != len(part1) { + t.Fatalf("bytesAppended mismatch on first append: got %d want %d", out1.BytesAppended, len(part1)) + } + + part2 := []byte(" world") + out2, stderr2, code2 := runFsAppend(t, bin, map[string]any{ + "path": path, + "contentBase64": base64.StdEncoding.EncodeToString(part2), + }) + if code2 != 0 { + t.Fatalf("second append expected success, got exit=%d stderr=%q", code2, stderr2) + } + if out2.BytesAppended != len(part2) { + t.Fatalf("bytesAppended mismatch on second append: got %d want %d", out2.BytesAppended, len(part2)) + } + + // Verify final file content + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read back: %v", err) + } + want := append(append([]byte{}, part1...), part2...) + if !bytes.Equal(got, want) { + t.Fatalf("content mismatch: got %q want %q", got, want) + } +} + +func TestFsAppend_Validation_MissingPath(t *testing.T) { + bin := testutil.BuildTool(t, "fs_append_file") + _, stderr, code := runFsAppend(t, bin, map[string]any{ + "path": "", + "contentBase64": base64.StdEncoding.EncodeToString([]byte("data")), + }) + if code == 0 { + t.Fatalf("expected non-zero exit for missing path") + } + if !strings.Contains(strings.ToLower(stderr), "path is required") { + t.Fatalf("stderr should mention path is required, got %q", stderr) + } +} + +func TestFsAppend_Validation_MissingContent(t *testing.T) { + bin := testutil.BuildTool(t, "fs_append_file") + dir := testutil.MakeRepoRelTempDir(t, "fsappend-validate-") + path := filepath.Join(dir, "x.txt") + _, stderr, code := runFsAppend(t, bin, map[string]any{ + "path": path, + "contentBase64": "", + }) + if code == 0 { + t.Fatalf("expected non-zero exit for missing contentBase64") + } + if !strings.Contains(strings.ToLower(stderr), "contentbase64 is required") { + t.Fatalf("stderr should mention contentBase64 is required, got %q", stderr) + } +} + +func TestFsAppend_Validation_AbsolutePath(t *testing.T) { + bin := testutil.BuildTool(t, "fs_append_file") + abs := filepath.Join("/", "tmp", "x.txt") + _, stderr, code := runFsAppend(t, bin, map[string]any{ + "path": abs, + "contentBase64": base64.StdEncoding.EncodeToString([]byte("x")), + }) + if code == 0 { + t.Fatalf("expected non-zero exit for absolute path") + } + if !strings.Contains(strings.ToLower(stderr), "path must be relative to repository root") { + t.Fatalf("stderr should mention relative path requirement, got %q", stderr) + } +} + +func TestFsAppend_Validation_PathEscape(t *testing.T) { + bin := testutil.BuildTool(t, "fs_append_file") + _, stderr, code := runFsAppend(t, bin, map[string]any{ + "path": filepath.Join("..", "escape.txt"), + "contentBase64": base64.StdEncoding.EncodeToString([]byte("x")), + }) + if code == 0 { + t.Fatalf("expected non-zero exit for path escape") + } + if !strings.Contains(strings.ToLower(stderr), "path escapes repository root") { + t.Fatalf("stderr should mention path escapes repository root, got %q", stderr) + } +} + +func TestFsAppend_Validation_BadBase64(t *testing.T) { + bin := testutil.BuildTool(t, "fs_append_file") + dir := testutil.MakeRepoRelTempDir(t, "fsappend-validate-") + path := filepath.Join(dir, "bad.txt") + _, stderr, code := runFsAppend(t, bin, map[string]any{ + "path": path, + "contentBase64": "!!!not-base64!!!", + }) + if code == 0 { + t.Fatalf("expected non-zero exit for bad base64") + } + if !strings.Contains(strings.ToLower(stderr), "decode base64") { + t.Fatalf("stderr should mention base64 decode failure, got %q", stderr) + } +} + +func TestFsAppend_ConcurrentWriters(t *testing.T) { + bin := testutil.BuildTool(t, "fs_append_file") + + dir := testutil.MakeRepoRelTempDir(t, "fsappend-concurrent-") + path := filepath.Join(dir, "concurrent.txt") + + // Distinct payloads to allow order-agnostic verification via counts + partA := bytes.Repeat([]byte("A"), 10000) + partB := bytes.Repeat([]byte("B"), 12000) + + var wg sync.WaitGroup + wg.Add(2) + + var out1 fsAppendOutput + var err1 string + var code1 int + go func() { + defer wg.Done() + out1, err1, code1 = runFsAppend(t, bin, map[string]any{ + "path": path, + "contentBase64": base64.StdEncoding.EncodeToString(partA), + }) + }() + + var out2 fsAppendOutput + var err2 string + var code2 int + go func() { + defer wg.Done() + out2, err2, code2 = runFsAppend(t, bin, map[string]any{ + "path": path, + "contentBase64": base64.StdEncoding.EncodeToString(partB), + }) + }() + + wg.Wait() + + if code1 != 0 { + t.Fatalf("first concurrent append expected success, got exit=%d stderr=%q", code1, err1) + } + if code2 != 0 { + t.Fatalf("second concurrent append expected success, got exit=%d stderr=%q", code2, err2) + } + if out1.BytesAppended != len(partA) { + t.Fatalf("bytesAppended mismatch for first writer: got %d want %d", out1.BytesAppended, len(partA)) + } + if out2.BytesAppended != len(partB) { + t.Fatalf("bytesAppended mismatch for second writer: got %d want %d", out2.BytesAppended, len(partB)) + } + + // Verify final content length and composition (order-agnostic) + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read back: %v", err) + } + wantLen := len(partA) + len(partB) + if len(got) != wantLen { + t.Fatalf("final size mismatch: got %d want %d", len(got), wantLen) + } + var countA, countB int + for _, b := range got { + if b == 'A' { + countA++ + } else if b == 'B' { + countB++ + } + } + if countA != len(partA) || countB != len(partB) { + t.Fatalf("content composition mismatch: countA=%d want %d, countB=%d want %d", countA, len(partA), countB, len(partB)) + } +} + +// TestFsAppend_ErrorJSON_PathRequired verifies standardized stderr JSON error +// contract: when required input is missing (path/content), the tool writes a +// single-line JSON object with an "error" key to stderr and exits non-zero. +func TestFsAppend_ErrorJSON_PathRequired(t *testing.T) { + bin := testutil.BuildTool(t, "fs_append_file") + var stdout, stderr bytes.Buffer + cmd := exec.Command(bin) + cmd.Stdout = &stdout + cmd.Stderr = &stderr + cmd.Stdin = bytes.NewBufferString("{}") + err := cmd.Run() + if err == nil { + t.Fatalf("expected non-zero exit for missing fields; stderr=%q", stderr.String()) + } + line := strings.TrimSpace(stderr.String()) + var obj map[string]any + if jerr := json.Unmarshal([]byte(line), &obj); jerr != nil { + t.Fatalf("stderr is not JSON: %q err=%v", line, jerr) + } + if _, ok := obj["error"]; !ok { + t.Fatalf("stderr JSON missing 'error' key: %v", obj) + } +} diff --git a/tools/cmd/fs_read_file/fs_read_file.go b/tools/cmd/fs_read_file/fs_read_file.go new file mode 100644 index 0000000..6f69259 --- /dev/null +++ b/tools/cmd/fs_read_file/fs_read_file.go @@ -0,0 +1,140 @@ +package main + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// inputSpec models the stdin JSON contract for fs_read_file. +// {"path":"string","offsetBytes?:int,"maxBytes?:int} +type inputSpec struct { + Path string `json:"path"` + OffsetBytes int64 `json:"offsetBytes"` + MaxBytes int64 `json:"maxBytes"` +} + +// outputSpec is the stdout JSON contract on success. +// {"contentBase64":"string","sizeBytes":int,"eof":bool} +type outputSpec struct { + ContentBase64 string `json:"contentBase64"` + SizeBytes int64 `json:"sizeBytes"` + EOF bool `json:"eof"` +} + +func main() { + if err := run(); err != nil { + // Standardized error JSON contract: single-line {"error":"..."} to stderr + // Preserve NOT_FOUND marker prefix when applicable for deterministic tests. + msg := strings.TrimSpace(err.Error()) + if errors.Is(err, os.ErrNotExist) || strings.Contains(strings.ToUpper(msg), "NOT_FOUND") { + // Ensure NOT_FOUND appears in the message for existing tests + msg = "NOT_FOUND: " + msg + } + if encErr := json.NewEncoder(os.Stderr).Encode(map[string]string{"error": msg}); encErr != nil { + // Fallback to raw stderr write if JSON encoding fails + fmt.Fprintf(os.Stderr, "{\"error\":%q}\n", msg) + } + os.Exit(1) + } +} + +// nolint:gocyclo // IO validation + ranged read; complexity slightly above threshold and covered by tests. +func run() error { + inBytes, err := io.ReadAll(os.Stdin) + if err != nil { + return fmt.Errorf("read stdin: %w", err) + } + if len(strings.TrimSpace(string(inBytes))) == 0 { + return fmt.Errorf("missing json input") + } + var in inputSpec + if err := json.Unmarshal(inBytes, &in); err != nil { + return fmt.Errorf("bad json: %w", err) + } + if strings.TrimSpace(in.Path) == "" { + return fmt.Errorf("path is required") + } + // Enforce repo-relative paths: disallow absolute and path escape above CWD. + if filepath.IsAbs(in.Path) { + return fmt.Errorf("path must be relative to repository root") + } + clean := filepath.Clean(in.Path) + if strings.HasPrefix(clean, "..") { + return fmt.Errorf("path escapes repository root") + } + if in.OffsetBytes < 0 { + return fmt.Errorf("offsetBytes must be >= 0") + } + // Open and stat to determine file size. + f, err := os.Open(clean) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("NOT_FOUND: %s", clean) + } + return fmt.Errorf("open file: %w", err) + } + // Do not ignore close errors; close explicitly before emitting output + + info, err := f.Stat() + if err != nil { + return fmt.Errorf("stat file: %w", err) + } + size := info.Size() + + // If offset beyond end, return empty content with eof=true. + if in.OffsetBytes >= size { + out := outputSpec{ContentBase64: "", SizeBytes: size, EOF: true} + return writeJSON(out) + } + + if _, err := f.Seek(in.OffsetBytes, io.SeekStart); err != nil { + return fmt.Errorf("seek: %w", err) + } + + // Determine how many bytes to read. + var toRead int64 = size - in.OffsetBytes + if in.MaxBytes > 0 && in.MaxBytes < toRead { + toRead = in.MaxBytes + } + if toRead < 0 { + toRead = 0 + } + + // Read the requested range. + buf := make([]byte, toRead) + var readTotal int64 + for readTotal < toRead { + n, rerr := f.Read(buf[readTotal:]) + if n > 0 { + readTotal += int64(n) + } + if rerr != nil { + if errors.Is(rerr, io.EOF) { + break + } + return fmt.Errorf("read: %w", rerr) + } + } + // Close the file and surface errors before writing JSON to stdout + if cerr := f.Close(); cerr != nil { + return fmt.Errorf("close: %w", cerr) + } + eof := in.OffsetBytes+readTotal >= size + out := outputSpec{ContentBase64: base64.StdEncoding.EncodeToString(buf[:readTotal]), SizeBytes: size, EOF: eof} + return writeJSON(out) +} + +func writeJSON(v any) error { + b, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("marshal: %w", err) + } + fmt.Println(string(b)) + return nil +} diff --git a/tools/cmd/fs_read_file/fs_read_file_test.go b/tools/cmd/fs_read_file/fs_read_file_test.go new file mode 100644 index 0000000..7693538 --- /dev/null +++ b/tools/cmd/fs_read_file/fs_read_file_test.go @@ -0,0 +1,183 @@ +package main + +// https://github.com/hyperifyio/goagent/issues/1 + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/hyperifyio/goagent/tools/testutil" +) + +type fsReadOutput struct { + ContentBase64 string `json:"contentBase64"` + SizeBytes int64 `json:"sizeBytes"` + EOF bool `json:"eof"` +} + +// build via tools/testutil.BuildTool after migration to tools/cmd/fs_read_file + +// runFsRead runs the built fs_read_file tool with the given JSON input and decodes stdout. +func runFsRead(t *testing.T, bin string, input any) (fsReadOutput, string, int) { + t.Helper() + data, err := json.Marshal(input) + if err != nil { + t.Fatalf("marshal input: %v", err) + } + cmd := exec.Command(bin) + cmd.Dir = "." + cmd.Stdin = bytes.NewReader(data) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err = cmd.Run() + code := 0 + if err != nil { + if ee, ok := err.(*exec.ExitError); ok { + code = ee.ExitCode() + } else { + code = 1 + } + } + var out fsReadOutput + if code == 0 { + if err := json.Unmarshal([]byte(strings.TrimSpace(stdout.String())), &out); err != nil { + t.Fatalf("unmarshal stdout: %v; raw=%q", err, stdout.String()) + } + } + return out, stderr.String(), code +} + +func makeRepoRelTempFile(t *testing.T, dirPrefix string, data []byte) (relPath string) { + t.Helper() + // Create a temp directory under repo root (current directory). + tmpAbs, err := os.MkdirTemp(".", dirPrefix) + if err != nil { + t.Fatalf("mkdir temp under repo: %v", err) + } + base := filepath.Base(tmpAbs) + fileRel := filepath.Join(base, "file.bin") + if err := os.WriteFile(fileRel, data, 0o644); err != nil { + t.Fatalf("write temp file: %v", err) + } + t.Cleanup(func() { + if err := os.RemoveAll(base); err != nil { + t.Logf("cleanup remove %s: %v", base, err) + } + }) + return fileRel +} + +func TestFsRead_TextFile(t *testing.T) { + bin := testutil.BuildTool(t, "fs_read_file") + content := []byte("hello world\n") + path := makeRepoRelTempFile(t, "fsread-text-", content) + out, stderr, code := runFsRead(t, bin, map[string]any{ + "path": path, + }) + if code != 0 { + t.Fatalf("expected success, got exit=%d stderr=%q", code, stderr) + } + if out.SizeBytes != int64(len(content)) { + t.Fatalf("sizeBytes mismatch: got %d want %d", out.SizeBytes, len(content)) + } + if !out.EOF { + t.Fatalf("expected EOF=true") + } + decoded, err := base64.StdEncoding.DecodeString(out.ContentBase64) + if err != nil { + t.Fatalf("base64 decode: %v", err) + } + if !bytes.Equal(decoded, content) { + t.Fatalf("content mismatch: got %q want %q", decoded, content) + } +} + +func TestFsRead_BinaryRoundTrip(t *testing.T) { + bin := testutil.BuildTool(t, "fs_read_file") + data := []byte{0x00, 0x10, 0xFF, 0x42, 0x00} + path := makeRepoRelTempFile(t, "fsread-bin-", data) + out, stderr, code := runFsRead(t, bin, map[string]any{"path": path}) + if code != 0 { + t.Fatalf("expected success, got exit=%d stderr=%q", code, stderr) + } + decoded, err := base64.StdEncoding.DecodeString(out.ContentBase64) + if err != nil { + t.Fatalf("decode: %v", err) + } + if !bytes.Equal(decoded, data) { + t.Fatalf("binary mismatch: got %v want %v", decoded, data) + } +} + +func TestFsRead_Ranges(t *testing.T) { + bin := testutil.BuildTool(t, "fs_read_file") + data := []byte("abcdefg") + path := makeRepoRelTempFile(t, "fsread-range-", data) + // offset=2, max=3 -> cde, eof=false + out1, stderr1, code1 := runFsRead(t, bin, map[string]any{"path": path, "offsetBytes": 2, "maxBytes": 3}) + if code1 != 0 { + t.Fatalf("expected success, got exit=%d stderr=%q", code1, stderr1) + } + b1, err := base64.StdEncoding.DecodeString(out1.ContentBase64) + if err != nil { + t.Fatalf("decode b1: %v", err) + } + if string(b1) != "cde" || out1.EOF { + t.Fatalf("unexpected range1: content=%q eof=%v", string(b1), out1.EOF) + } + // offset=5, max=10 -> fg, eof=true + out2, stderr2, code2 := runFsRead(t, bin, map[string]any{"path": path, "offsetBytes": 5, "maxBytes": 10}) + if code2 != 0 { + t.Fatalf("expected success, got exit=%d stderr=%q", code2, stderr2) + } + b2, err := base64.StdEncoding.DecodeString(out2.ContentBase64) + if err != nil { + t.Fatalf("decode b2: %v", err) + } + if string(b2) != "fg" || !out2.EOF { + t.Fatalf("unexpected range2: content=%q eof=%v", string(b2), out2.EOF) + } +} + +func TestFsRead_NotFound(t *testing.T) { + bin := testutil.BuildTool(t, "fs_read_file") + _, stderr, code := runFsRead(t, bin, map[string]any{"path": "this/does/not/exist.txt"}) + if code == 0 { + t.Fatalf("expected non-zero exit for missing file") + } + if !strings.Contains(strings.ToUpper(stderr), "NOT_FOUND") { + t.Fatalf("stderr should contain NOT_FOUND, got %q", stderr) + } +} + +// TestFsRead_ErrorJSON verifies standardized error contract: on failure, +// the tool writes a single-line JSON object to stderr with an "error" key +// and exits non-zero. +func TestFsRead_ErrorJSON(t *testing.T) { + bin := testutil.BuildTool(t, "fs_read_file") + + // Use an absolute path to trigger validation failure (repo-relative enforced). + abs := string(os.PathSeparator) + filepath.Join("tmp", "fsread-abs.txt") + + _, stderr, code := runFsRead(t, bin, map[string]any{ + "path": abs, + }) + if code == 0 { + t.Fatalf("expected non-zero exit on invalid absolute path") + } + line := strings.TrimSpace(stderr) + var obj map[string]any + if err := json.Unmarshal([]byte(line), &obj); err != nil { + t.Fatalf("stderr is not JSON: %q err=%v", line, err) + } + if _, ok := obj["error"]; !ok { + t.Fatalf("stderr JSON missing 'error' key: %v", obj) + } +} diff --git a/tools/cmd/fs_rm/fs_rm.go b/tools/cmd/fs_rm/fs_rm.go new file mode 100644 index 0000000..37f189d --- /dev/null +++ b/tools/cmd/fs_rm/fs_rm.go @@ -0,0 +1,94 @@ +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +type rmInput struct { + Path string `json:"path"` + Recursive bool `json:"recursive,omitempty"` + Force bool `json:"force,omitempty"` +} + +type rmOutput struct { + Removed bool `json:"removed"` +} + +func main() { + in, err := readInput(os.Stdin) + if err != nil { + stderrJSON(err) + os.Exit(1) + } + if err := validatePath(in.Path); err != nil { + stderrJSON(err) + os.Exit(1) + } + removed, err := removePath(in.Path, in.Recursive, in.Force) + if err != nil { + stderrJSON(err) + os.Exit(1) + } + if err := json.NewEncoder(os.Stdout).Encode(rmOutput{Removed: removed}); err != nil { + stderrJSON(fmt.Errorf("encode json: %w", err)) + os.Exit(1) + } +} + +func readInput(r io.Reader) (rmInput, error) { + var in rmInput + b, err := io.ReadAll(bufio.NewReader(r)) + if err != nil { + return in, fmt.Errorf("read stdin: %w", err) + } + if err := json.Unmarshal(b, &in); err != nil { + return in, fmt.Errorf("parse json: %w", err) + } + if strings.TrimSpace(in.Path) == "" { + return in, fmt.Errorf("path is required") + } + return in, nil +} + +func validatePath(p string) error { + if filepath.IsAbs(p) { + return fmt.Errorf("ABSOLUTE_PATH: %s", p) + } + clean := filepath.ToSlash(filepath.Clean(p)) + if strings.HasPrefix(clean, "../") || strings.Contains(clean, "/../") { + return fmt.Errorf("PATH_ESCAPE: %s", p) + } + return nil +} + +func removePath(path string, recursive, force bool) (bool, error) { + info, err := os.Lstat(path) + if err != nil { + if os.IsNotExist(err) { + if force { + return false, nil + } + return false, fmt.Errorf("NOT_FOUND: %s", path) + } + return false, err + } + if info.IsDir() { + if !recursive { + return false, fmt.Errorf("IS_DIR: %s", path) + } + return true, os.RemoveAll(path) + } + return true, os.Remove(path) +} + +func stderrJSON(err error) { + msg := err.Error() + msg = strings.ReplaceAll(msg, "\n", " ") + fmt.Fprintf(os.Stderr, "{\"error\":%q}\n", msg) +} diff --git a/tools/cmd/fs_rm/fs_rm_test.go b/tools/cmd/fs_rm/fs_rm_test.go new file mode 100644 index 0000000..4a56dce --- /dev/null +++ b/tools/cmd/fs_rm/fs_rm_test.go @@ -0,0 +1,150 @@ +package main + +// https://github.com/hyperifyio/goagent/issues/1 + +import ( + "bytes" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/hyperifyio/goagent/tools/testutil" +) + +type fsRmOutput struct { + Removed bool `json:"removed"` +} + +// runFsRm runs the built fs_rm tool with the given JSON input and decodes stdout. +func runFsRm(t *testing.T, bin string, input any) (fsRmOutput, string, int) { + t.Helper() + data, err := json.Marshal(input) + if err != nil { + t.Fatalf("marshal input: %v", err) + } + cmd := exec.Command(bin) + cmd.Dir = "." + cmd.Stdin = bytes.NewReader(data) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err = cmd.Run() + code := 0 + if err != nil { + if ee, ok := err.(*exec.ExitError); ok { + code = ee.ExitCode() + } else { + code = 1 + } + } + var out fsRmOutput + if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &out); err != nil { + t.Fatalf("unmarshal stdout: %v; raw=%q", err, stdout.String()) + } + return out, stderr.String(), code +} + +// Use shared helper from tools/testutil instead of local duplicate. + +// TestFsRm_DeleteFile expresses the contract: deleting a regular file succeeds, +// tool exits 0, outputs {"removed":true}, and the file no longer exists. +func TestFsRm_DeleteFile(t *testing.T) { + bin := testutil.BuildTool(t, "fs_rm") + + dir := testutil.MakeRepoRelTempDir(t, "fsrm-") + path := filepath.Join(dir, "target.txt") + if err := os.WriteFile(path, []byte("data"), 0o644); err != nil { + t.Fatalf("seed file: %v", err) + } + + out, stderr, code := runFsRm(t, bin, map[string]any{ + "path": path, + }) + if code != 0 { + t.Fatalf("expected success, got exit=%d stderr=%q", code, stderr) + } + if !out.Removed { + t.Fatalf("expected removed=true, got false") + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("expected file to be removed, stat err=%v", err) + } +} + +// TestFsRm_DeleteDirRecursive expresses the next contract: deleting a directory +// tree with recursive=true succeeds, tool exits 0, outputs {"removed":true}, +// and the directory no longer exists. +func TestFsRm_DeleteDirRecursive(t *testing.T) { + bin := testutil.BuildTool(t, "fs_rm") + + dir := testutil.MakeRepoRelTempDir(t, "fsrm-dir-") + deep := filepath.Join(dir, "a", "b") + if err := os.MkdirAll(deep, 0o755); err != nil { + t.Fatalf("mkdir tree: %v", err) + } + if err := os.WriteFile(filepath.Join(deep, "file.txt"), []byte("x"), 0o644); err != nil { + t.Fatalf("seed file: %v", err) + } + + out, stderr, code := runFsRm(t, bin, map[string]any{ + "path": dir, + "recursive": true, + }) + if code != 0 { + t.Fatalf("expected success, got exit=%d stderr=%q", code, stderr) + } + if !out.Removed { + t.Fatalf("expected removed=true, got false") + } + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Fatalf("expected directory to be removed, stat err=%v", err) + } +} + +// TestFsRm_ErrorJSON_PathRequired verifies that errors are reported as single-line +// JSON to stderr with an "error" field when required input is missing. +func TestFsRm_ErrorJSON_PathRequired(t *testing.T) { + bin := testutil.BuildTool(t, "fs_rm") + + cmd := exec.Command(bin) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + cmd.Stdin = bytes.NewBufferString("{}") + err := cmd.Run() + if err == nil { + t.Fatalf("expected non-zero exit for missing path; stderr=%q", stderr.String()) + } + var payload map[string]any + if jerr := json.Unmarshal(bytes.TrimSpace(stderr.Bytes()), &payload); jerr != nil { + t.Fatalf("stderr is not valid JSON: %v; got %q", jerr, stderr.String()) + } + if _, ok := payload["error"]; !ok { + t.Fatalf("stderr JSON missing 'error' field: %v", payload) + } +} + +// TestFsRm_ForceOnMissing verifies force=true on a missing path exits 0, +// returns {"removed":false}, and the path remains absent. +func TestFsRm_ForceOnMissing(t *testing.T) { + bin := testutil.BuildTool(t, "fs_rm") + + dir := testutil.MakeRepoRelTempDir(t, "fsrm-missing-") + path := filepath.Join(dir, "absent.txt") + + out, stderr, code := runFsRm(t, bin, map[string]any{ + "path": path, + "force": true, + }) + if code != 0 { + t.Fatalf("expected success, got exit=%d stderr=%q", code, stderr) + } + if out.Removed { + t.Fatalf("expected removed=false for missing path with force=true") + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("expected path to be absent, stat err=%v", err) + } +} diff --git a/tools/cmd/fs_write_file/fs_write_file.go b/tools/cmd/fs_write_file/fs_write_file.go new file mode 100644 index 0000000..6dc3676 --- /dev/null +++ b/tools/cmd/fs_write_file/fs_write_file.go @@ -0,0 +1,128 @@ +package main + +import ( + "bufio" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +type writeInput struct { + Path string `json:"path"` + ContentBase64 string `json:"contentBase64"` + CreateModeOctal string `json:"createModeOctal,omitempty"` +} + +type writeOutput struct { + BytesWritten int `json:"bytesWritten"` +} + +func main() { + in, err := readInput(os.Stdin) + if err != nil { + stderrJSON(err) + os.Exit(1) + } + if err := validatePath(in.Path); err != nil { + stderrJSON(err) + os.Exit(1) + } + data, err := base64.StdEncoding.DecodeString(in.ContentBase64) + if err != nil { + stderrJSON(fmt.Errorf("BAD_BASE64: %w", err)) + os.Exit(1) + } + // Require parent directory to exist; do not create it implicitly + parent := filepath.Dir(in.Path) + if st, err := os.Stat(parent); err != nil || !st.IsDir() { + if err == nil { + // exists but not a directory + stderrJSON(fmt.Errorf("MISSING_PARENT: %s is not a directory", parent)) + } else if os.IsNotExist(err) { + stderrJSON(fmt.Errorf("MISSING_PARENT: %s", parent)) + } else { + stderrJSON(fmt.Errorf("MISSING_PARENT: %v", err)) + } + os.Exit(1) + } + mode := os.FileMode(0o644) + if strings.TrimSpace(in.CreateModeOctal) != "" { + if m, perr := parseOctalMode(in.CreateModeOctal); perr == nil { + mode = m + } + } + if err := atomicWriteFile(in.Path, data, mode); err != nil { + stderrJSON(err) + os.Exit(1) + } + enc := json.NewEncoder(os.Stdout) + if err := enc.Encode(writeOutput{BytesWritten: len(data)}); err != nil { + // Ensure a deterministic non-zero exit with stderr JSON on failure + stderrJSON(fmt.Errorf("encode stdout: %w", err)) + os.Exit(1) + } +} + +func readInput(r io.Reader) (writeInput, error) { + var in writeInput + br := bufio.NewReader(r) + b, err := io.ReadAll(br) + if err != nil { + return in, fmt.Errorf("read stdin: %w", err) + } + if err := json.Unmarshal(b, &in); err != nil { + return in, fmt.Errorf("parse json: %w", err) + } + if strings.TrimSpace(in.Path) == "" { + return in, errors.New("path is required") + } + if strings.TrimSpace(in.ContentBase64) == "" { + return in, errors.New("contentBase64 is required") + } + return in, nil +} + +func validatePath(p string) error { + if filepath.IsAbs(p) { + return fmt.Errorf("ABSOLUTE_PATH: %s", p) + } + clean := filepath.ToSlash(filepath.Clean(p)) + if strings.HasPrefix(clean, "../") || strings.Contains(clean, "/../") { + return fmt.Errorf("PATH_ESCAPE: %s", p) + } + return nil +} + +func parseOctalMode(s string) (os.FileMode, error) { + var m uint32 + _, err := fmt.Sscanf(s, "%o", &m) + if err != nil { + return 0, err + } + return os.FileMode(m), nil +} + +func atomicWriteFile(path string, data []byte, mode os.FileMode) error { + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, mode); err != nil { + return err + } + if err := os.Rename(tmp, path); err != nil { + if rmErr := os.Remove(tmp); rmErr != nil { + _ = rmErr // ignore cleanup error + } + return err + } + return nil +} + +func stderrJSON(err error) { + msg := err.Error() + msg = strings.ReplaceAll(msg, "\n", " ") + fmt.Fprintf(os.Stderr, "{\"error\":%q}\n", msg) +} diff --git a/tools/cmd/fs_write_file/fs_write_file_test.go b/tools/cmd/fs_write_file/fs_write_file_test.go new file mode 100644 index 0000000..ae32bf3 --- /dev/null +++ b/tools/cmd/fs_write_file/fs_write_file_test.go @@ -0,0 +1,160 @@ +package main + +// https://github.com/hyperifyio/goagent/issues/1 + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/hyperifyio/goagent/tools/testutil" +) + +type fsWriteOutput struct { + BytesWritten int `json:"bytesWritten"` +} + +// runFsWrite runs the built fs_write_file tool with the given JSON input. +func runFsWrite(t *testing.T, bin string, input any) (fsWriteOutput, string, int) { + t.Helper() + data, err := json.Marshal(input) + if err != nil { + t.Fatalf("marshal input: %v", err) + } + cmd := exec.Command(bin) + cmd.Dir = "." + cmd.Stdin = bytes.NewReader(data) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err = cmd.Run() + code := 0 + if err != nil { + if ee, ok := err.(*exec.ExitError); ok { + code = ee.ExitCode() + } else { + code = 1 + } + } + var out fsWriteOutput + if code == 0 { + if err := json.Unmarshal([]byte(strings.TrimSpace(stdout.String())), &out); err != nil { + t.Fatalf("unmarshal stdout: %v; raw=%q", err, stdout.String()) + } + } + return out, stderr.String(), code +} + +// makeRepoRelTempDir is now provided by tools/testutil.MakeRepoRelTempDir. + +func TestFsWrite_CreateText(t *testing.T) { + bin := testutil.BuildTool(t, "fs_write_file") + dir := testutil.MakeRepoRelTempDir(t, "fswrite-text-") + path := filepath.Join(dir, "hello.txt") + content := []byte("hello world\n") + out, stderr, code := runFsWrite(t, bin, map[string]any{ + "path": path, + "contentBase64": base64.StdEncoding.EncodeToString(content), + }) + if code != 0 { + t.Fatalf("expected success, got exit=%d stderr=%q", code, stderr) + } + if out.BytesWritten != len(content) { + t.Fatalf("bytesWritten mismatch: got %d want %d", out.BytesWritten, len(content)) + } + readBack, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read back: %v", err) + } + if !bytes.Equal(readBack, content) { + t.Fatalf("content mismatch: got %q want %q", readBack, content) + } +} + +func TestFsWrite_Overwrite(t *testing.T) { + bin := testutil.BuildTool(t, "fs_write_file") + dir := testutil.MakeRepoRelTempDir(t, "fswrite-over-") + path := filepath.Join(dir, "data.bin") + // Seed with initial content + if err := os.WriteFile(path, []byte("old"), 0o644); err != nil { + t.Fatalf("seed write: %v", err) + } + newContent := []byte("new-content") + out, stderr, code := runFsWrite(t, bin, map[string]any{ + "path": path, + "contentBase64": base64.StdEncoding.EncodeToString(newContent), + }) + if code != 0 { + t.Fatalf("expected success, got exit=%d stderr=%q", code, stderr) + } + if out.BytesWritten != len(newContent) { + t.Fatalf("bytesWritten mismatch: got %d want %d", out.BytesWritten, len(newContent)) + } + readBack, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read back: %v", err) + } + if !bytes.Equal(readBack, newContent) { + t.Fatalf("overwrite failed: got %q want %q", readBack, newContent) + } +} + +func TestFsWrite_Binary(t *testing.T) { + bin := testutil.BuildTool(t, "fs_write_file") + dir := testutil.MakeRepoRelTempDir(t, "fswrite-bin-") + path := filepath.Join(dir, "bytes.bin") + data := []byte{0x00, 0x10, 0xFF, 0x42, 0x00} + out, stderr, code := runFsWrite(t, bin, map[string]any{ + "path": path, + "contentBase64": base64.StdEncoding.EncodeToString(data), + }) + if code != 0 { + t.Fatalf("expected success, got exit=%d stderr=%q", code, stderr) + } + if out.BytesWritten != len(data) { + t.Fatalf("bytesWritten mismatch: got %d want %d", out.BytesWritten, len(data)) + } + readBack, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read back: %v", err) + } + if !bytes.Equal(readBack, data) { + t.Fatalf("binary mismatch: got %v want %v", readBack, data) + } +} + +func TestFsWrite_MissingParent(t *testing.T) { + bin := testutil.BuildTool(t, "fs_write_file") + path := filepath.Join("no_such_parent_dir", "x", "file.txt") + _, stderr, code := runFsWrite(t, bin, map[string]any{ + "path": path, + "contentBase64": base64.StdEncoding.EncodeToString([]byte("x")), + }) + if code == 0 { + t.Fatalf("expected non-zero exit for missing parent") + } + if !strings.Contains(strings.ToUpper(stderr), "MISSING_PARENT") { + t.Fatalf("stderr should contain MISSING_PARENT, got %q", stderr) + } +} + +// TestFsWrite_ErrorJSON_PathRequired verifies standardized error contract on missing required fields. +func TestFsWrite_ErrorJSON_PathRequired(t *testing.T) { + bin := testutil.BuildTool(t, "fs_write_file") + // Omit path to trigger validation error in readInput + _, stderr, code := runFsWrite(t, bin, map[string]any{ + "contentBase64": base64.StdEncoding.EncodeToString([]byte("hello")), + }) + if code == 0 { + t.Fatalf("expected non-zero exit code for missing path") + } + s := strings.TrimSpace(stderr) + if s == "" || !strings.Contains(s, "\"error\"") { + t.Fatalf("stderr should contain JSON with 'error' field, got: %q", stderr) + } +} diff --git a/tools/testutil/buildtool.go b/tools/testutil/buildtool.go new file mode 100644 index 0000000..b8a3456 --- /dev/null +++ b/tools/testutil/buildtool.go @@ -0,0 +1,86 @@ +package testutil + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" +) + +// BuildTool builds the named tool binary into a test-scoped temporary +// directory and returns the absolute path to the produced executable. +// +// Source discovery (absolute paths used to satisfy repository path hygiene +// rules in linters/tests): +// - tools/cmd/ (canonical layout only) +func BuildTool(t *testing.T, name string) string { + t.Helper() + + repoRoot, err := findRepoRoot() + if err != nil { + t.Fatalf("find repo root: %v", err) + } + + // Determine binary name with OS suffix + binName := name + if runtime.GOOS == "windows" { + binName += ".exe" + } + outPath := filepath.Join(t.TempDir(), binName) + + // Candidate source locations (canonical layout only) + var candidates []string + candidates = append(candidates, filepath.Join(repoRoot, "tools", "cmd", name)) + + var srcPath string + for _, c := range candidates { + if fi, statErr := os.Stat(c); statErr == nil { + // Accept directories and regular files + if fi.IsDir() || fi.Mode().IsRegular() { + srcPath = c + break + } + } + } + if srcPath == "" { + t.Fatalf("tool sources not found for %q under %s", name, filepath.Join(repoRoot, "tools")) + } + + cmd := exec.Command("go", "build", "-o", outPath, srcPath) + cmd.Dir = repoRoot + // Inherit environment; ensure CGO disabled for determinism + cmd.Env = append(os.Environ(), "CGO_ENABLED=0") + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("build %s from %s failed: %v\n%s", name, relOrSame(repoRoot, srcPath), err, string(output)) + } + return outPath +} + +func findRepoRoot() (string, error) { + // Start from CWD and walk up until go.mod is found + start, err := os.Getwd() + if err != nil || start == "" { + return "", errors.New("cannot determine working directory") + } + dir := start + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir, nil + } + parent := filepath.Dir(dir) + if parent == dir { + return "", fmt.Errorf("go.mod not found from %s upward", start) + } + dir = parent + } +} + +func relOrSame(base, target string) string { + if rel, err := filepath.Rel(base, target); err == nil { + return rel + } + return target +} diff --git a/tools/testutil/buildtool_test.go b/tools/testutil/buildtool_test.go new file mode 100644 index 0000000..53618d3 --- /dev/null +++ b/tools/testutil/buildtool_test.go @@ -0,0 +1,21 @@ +package testutil + +import ( + "runtime" + "strings" + "testing" +) + +func TestBuildTool_WindowsSuffix(t *testing.T) { + // Use a real tool name to ensure build succeeds across environments. + path := BuildTool(t, "fs_listdir") + if runtime.GOOS == "windows" { + if !strings.HasSuffix(path, ".exe") { + t.Fatalf("expected .exe suffix on Windows, got %q", path) + } + } else { + if strings.HasSuffix(path, ".exe") { + t.Fatalf("did not expect .exe suffix on non-Windows, got %q", path) + } + } +} diff --git a/tools/testutil/tempdir.go b/tools/testutil/tempdir.go new file mode 100644 index 0000000..9fd3717 --- /dev/null +++ b/tools/testutil/tempdir.go @@ -0,0 +1,25 @@ +package testutil + +import ( + "os" + "path/filepath" + "testing" +) + +// MakeRepoRelTempDir creates a temporary directory under the current +// package working directory and returns its relative path (basename). +// The directory is removed at test cleanup. +func MakeRepoRelTempDir(t *testing.T, prefix string) string { + t.Helper() + tmpAbs, err := os.MkdirTemp(".", prefix) + if err != nil { + t.Fatalf("mkdir temp under repo: %v", err) + } + base := filepath.Base(tmpAbs) + t.Cleanup(func() { + if err := os.RemoveAll(base); err != nil { + t.Logf("cleanup remove %s: %v", base, err) + } + }) + return base +}