Files
orca/internal/sshpush/idempotency.go
T
Jon Chery c51eba5e84 fix(P99): P0 heredoc command injection + ROADMAP/REQUIREMENTS reconciliation
P0 fix (final review T1): internal/sshpush/idempotency.go heredoc
command injection via fixed EOF delimiter. Replaced with per-write random
delimiter verified absent from content (strings.Contains check). Fake SSH
server updated to parse the delimiter dynamically from the command. This
prevents command injection via crafted file content in multi-tenant
namespaces.

ROADMAP reconciliation (final review T2.1): updated v0.9 phase list to
reflect actual execution — 14 tagged phases (P03/P04/P08 combined,
P07a/b/c combined), tags v0.8.1..v0.8.14. Milestone marked COMPLETE.
Phase checkboxes marked [x] with actual REQs covered.

REQUIREMENTS reconciliation: 21 v0.9-scoped REQs marked Complete
(062,063,064,067,068,069,070,071,072,073,074,076,077,078,081,082,
083,085,088,089,090). 9 v0.10-deferred REQs (061,065,066,075,079,
080,084,086,087) Phase columns fixed to reference only v0.10 (not v0.9/v0.8)
so verify-reqs doesn't flag them as belonging to completed milestones.

Final review: P0 fixed. P1 warnings logged for post-hoc v0.10: fuzz in CI,
podman command quoting, scheduler O(n^2), ProcessRuntime stdout leak,
host-key verification path gap. 12/19 grill gates cleared; 7 deferred to
v0.10 (C-08,C-09,C-11,C-12,C-13,C-19).

26 packages pass, 20 bats pass, gofmt clean, verify-reqs 90 consistent.

---ci---
project: orca
phase: 99
milestone: v0.9
status: execute
---/ci---
2026-08-05 19:02:54 +00:00

104 lines
3.4 KiB
Go

package sshpush
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"math/rand"
"os"
"strings"
)
// WriteFileIdempotent writes content to peer:path atomically (write-to-tmp
// + mv, REQ-074) with mode, but only if the remote file's SHA-256 differs
// from the local content's SHA-256 (C-18 content-addressed idempotency —
// the SSH-push equivalent of the v0.8 X-Orca-Idempotency-Key).
//
// Returns written=true if the file was written, written=false if the
// content already matched (skip). The default per-SCP timeout is
// SCPTimeout (I-B-001).
//
// Atomicity: the content is written to a temp file in the same directory
// as the target, then `mv`'d into place. The temp file is mode-appended
// (e.g. `/etc/orca/foo.conf.orca-tmp-<rand>`) so the rename is atomic on
// POSIX filesystems.
func (t *Transport) WriteFileIdempotent(ctx context.Context, peer string, path string, content []byte, mode os.FileMode) (bool, error) {
localHash := sha256Hex(content)
remoteHash, err := t.remoteSHA256(ctx, peer, path)
if err == nil && remoteHash != "" && strings.EqualFold(remoteHash, localHash) {
return false, nil
}
if err := t.writeFile(ctx, peer, path, content, mode); err != nil {
return false, err
}
return true, nil
}
// writeFile writes content to peer:path atomically (write-to-tmp + mv).
// It writes the content via a single SSH exec (cat heredoc + chmod + mv),
// keeping the transfer in one round-trip. The temp file lives next to the
// target so the rename is atomic.
func (t *Transport) writeFile(ctx context.Context, peer string, path string, content []byte, mode os.FileMode) error {
dir, base := splitDir(path)
tmpName := fmt.Sprintf(".orca-tmp-%s", randomToken(8))
tmpPath := base + "/" + tmpName
if dir == "" {
tmpPath = tmpName
}
// Build the remote command: mkdir -p <dir> && cat > <tmp> <<'EOF'
// ... EOF && chmod <mode> <tmp> && mv <tmp> <path>. The heredoc
// delimiter is per-write random and verified absent from content
// to prevent command injection via crafted file content.
eof := "ORCA_PUSH_EOF_" + randomToken(16)
for strings.Contains(string(content), eof) {
eof = "ORCA_PUSH_EOF_" + randomToken(16)
}
modeStr := fmt.Sprintf("%04o", uint32(mode.Perm()))
cmd := fmt.Sprintf(
"mkdir -p %s && cat > %s <<'%s'\n%s\n%s\nchmod %s %s && mv -f %s %s",
shellQuote(base),
shellQuote(tmpPath),
eof,
string(content),
eof,
modeStr,
shellQuote(tmpPath),
shellQuote(tmpPath),
shellQuote(path),
)
execCtx, cancel := context.WithTimeout(ctx, SCPTimeout)
defer cancel()
if _, err := t.execWithRetry(execCtx, peer, cmd, true); err != nil {
return fmt.Errorf("sshpush: write %s: %w", path, err)
}
return nil
}
// sha256Hex returns the lowercase hex SHA-256 digest of b.
func sha256Hex(b []byte) string {
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}
// splitDir returns the directory and the directory itself (for mkdir).
// For "/etc/orca/foo.conf" it returns ("/etc/orca", "/etc/orca"). For
// "foo.conf" it returns ("", ".").
func splitDir(path string) (dir, base string) {
idx := strings.LastIndex(path, "/")
if idx < 0 {
return "", "."
}
return path[:idx], path[:idx]
}
// randomToken returns a random hex token of the given byte length. Used
// for temp-file naming to avoid collisions under parallel fan-out.
func randomToken(n int) string {
b := make([]byte, n)
for i := range b {
b[i] = byte(rand.Intn(256))
}
return hex.EncodeToString(b)
}