Fresh repository history for elektrine/tarakan-client hosted at https://git.elektrine.com/elektrine/tarakan-client.
240 lines
6.9 KiB
Go
240 lines
6.9 KiB
Go
package snapshot
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
var (
|
|
ErrCommitUnavailable = errors.New("pinned commit is not available in the local repository")
|
|
fullCommitPattern = regexp.MustCompile(`^[0-9a-fA-F]{40}$`)
|
|
)
|
|
|
|
// Snapshot is a disposable, metadata-free copy of one exact Git commit.
|
|
type Snapshot struct {
|
|
Root string
|
|
base string
|
|
baseline [sha256.Size]byte
|
|
}
|
|
|
|
// Create materializes commit from repositoryRoot without sharing Git object
|
|
// hardlinks. Hooks, global Git configuration, and interactive credential
|
|
// prompts are disabled for every Git subprocess involved in the snapshot.
|
|
func Create(repositoryRoot, commit string) (*Snapshot, error) {
|
|
if !fullCommitPattern.MatchString(commit) {
|
|
return nil, errors.New("task commit must be a full 40-character hexadecimal SHA")
|
|
}
|
|
if err := gitAvailable(repositoryRoot, commit); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
base, err := os.MkdirTemp("", "tarakan-snapshot-")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create snapshot directory: %w", err)
|
|
}
|
|
cleanup := true
|
|
defer func() {
|
|
if cleanup {
|
|
_ = os.RemoveAll(base)
|
|
}
|
|
}()
|
|
|
|
home := filepath.Join(base, "home")
|
|
if err := os.Mkdir(home, 0o700); err != nil {
|
|
return nil, fmt.Errorf("create isolated Git home: %w", err)
|
|
}
|
|
root := filepath.Join(base, "repository")
|
|
if output, err := runGit(base, home, "-c", "core.hooksPath=/dev/null", "clone", "--local", "--no-hardlinks", "--no-checkout", "--", repositoryRoot, root); err != nil {
|
|
return nil, fmt.Errorf("clone repository snapshot: %w: %s", err, strings.TrimSpace(output))
|
|
}
|
|
if output, err := runGit(base, home,
|
|
"-C", root,
|
|
"-c", "core.hooksPath=/dev/null",
|
|
"-c", "filter.lfs.smudge=",
|
|
"-c", "filter.lfs.required=false",
|
|
"checkout", "--detach", "--force", commit,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("check out pinned commit: %w: %s", err, strings.TrimSpace(output))
|
|
}
|
|
// Absolute or escaping symlinks would let the agent read host paths.
|
|
// Neutralize them (replace with a small text file) instead of failing:
|
|
// many real repos ship broken absolute "external/" pointers from another
|
|
// machine (e.g. /Users/... on Linux), and the review should still run.
|
|
if err := neutralizeExternalSymlinks(root); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := os.RemoveAll(filepath.Join(root, ".git")); err != nil {
|
|
return nil, fmt.Errorf("remove snapshot Git metadata: %w", err)
|
|
}
|
|
|
|
baseline, err := digestTree(root)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("hash repository snapshot: %w", err)
|
|
}
|
|
cleanup = false
|
|
return &Snapshot{Root: root, base: base, baseline: baseline}, nil
|
|
}
|
|
|
|
func neutralizeExternalSymlinks(root string) error {
|
|
return filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
if entry.Type()&os.ModeSymlink == 0 {
|
|
return nil
|
|
}
|
|
|
|
target, err := os.Readlink(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !isExternalSymlink(root, path, target) {
|
|
return nil
|
|
}
|
|
|
|
rel, relErr := filepath.Rel(root, path)
|
|
if relErr != nil {
|
|
rel = path
|
|
}
|
|
if err := os.Remove(path); err != nil {
|
|
return fmt.Errorf("remove external symlink %s: %w", filepath.ToSlash(rel), err)
|
|
}
|
|
// Regular file: agent cannot follow it to host content; original
|
|
// target is preserved as text so reviewers still see the pointer.
|
|
body := "tarakan-snapshot: neutralized external symlink\noriginal-target: " + target + "\n"
|
|
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
|
return fmt.Errorf("neutralize external symlink %s: %w", filepath.ToSlash(rel), err)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// isExternalSymlink is true for absolute targets or relative targets that
|
|
// resolve outside the snapshot root.
|
|
func isExternalSymlink(root, path, target string) bool {
|
|
if filepath.IsAbs(target) {
|
|
return true
|
|
}
|
|
resolved := filepath.Clean(filepath.Join(filepath.Dir(path), target))
|
|
relative, err := filepath.Rel(root, resolved)
|
|
if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Changed reports whether any path, mode, symlink target, or regular-file
|
|
// content differs from the snapshot presented to the agent.
|
|
func (s *Snapshot) Changed() (bool, error) {
|
|
current, err := digestTree(s.Root)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return current != s.baseline, nil
|
|
}
|
|
|
|
// Close destroys the snapshot. It is safe to call more than once.
|
|
func (s *Snapshot) Close() error {
|
|
if s == nil || s.base == "" {
|
|
return nil
|
|
}
|
|
base := s.base
|
|
s.base = ""
|
|
return os.RemoveAll(base)
|
|
}
|
|
|
|
func gitAvailable(repositoryRoot, commit string) error {
|
|
command := exec.Command("git", "-C", repositoryRoot, "cat-file", "-e", commit+"^{commit}")
|
|
command.Env = gitEnvironment(os.TempDir())
|
|
if err := command.Run(); err != nil {
|
|
return fmt.Errorf("%w: %s (run git fetch origin first)", ErrCommitUnavailable, commit)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func runGit(directory, home string, arguments ...string) (string, error) {
|
|
command := exec.Command("git", arguments...)
|
|
command.Dir = directory
|
|
command.Env = gitEnvironment(home)
|
|
output, err := command.CombinedOutput()
|
|
return string(output), err
|
|
}
|
|
|
|
func gitEnvironment(home string) []string {
|
|
filtered := make([]string, 0, len(os.Environ())+6)
|
|
for _, entry := range os.Environ() {
|
|
name, _, _ := strings.Cut(entry, "=")
|
|
switch strings.ToUpper(name) {
|
|
case "HOME", "XDG_CONFIG_HOME", "GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM", "GIT_CONFIG_NOSYSTEM", "GIT_TERMINAL_PROMPT", "GIT_ASKPASS", "SSH_ASKPASS":
|
|
continue
|
|
}
|
|
if strings.HasPrefix(strings.ToUpper(name), "TARAKAN_") {
|
|
continue
|
|
}
|
|
filtered = append(filtered, entry)
|
|
}
|
|
return append(filtered,
|
|
"HOME="+home,
|
|
"XDG_CONFIG_HOME="+filepath.Join(home, ".config"),
|
|
"GIT_CONFIG_NOSYSTEM=1",
|
|
"GIT_CONFIG_GLOBAL=/dev/null",
|
|
"GIT_TERMINAL_PROMPT=0",
|
|
)
|
|
}
|
|
|
|
func digestTree(root string) ([sha256.Size]byte, error) {
|
|
hash := sha256.New()
|
|
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
relative, err := filepath.Rel(root, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
info, err := entry.Info()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, _ = io.WriteString(hash, filepath.ToSlash(relative))
|
|
_, _ = io.WriteString(hash, "\x00"+info.Mode().String()+"\x00")
|
|
|
|
switch {
|
|
case info.Mode()&os.ModeSymlink != 0:
|
|
target, err := os.Readlink(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, _ = io.WriteString(hash, target)
|
|
case info.Mode().IsRegular():
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, copyErr := io.Copy(hash, file)
|
|
closeErr := file.Close()
|
|
if copyErr != nil {
|
|
return copyErr
|
|
}
|
|
if closeErr != nil {
|
|
return closeErr
|
|
}
|
|
}
|
|
_, _ = io.WriteString(hash, "\x00")
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return [sha256.Size]byte{}, err
|
|
}
|
|
var result [sha256.Size]byte
|
|
copy(result[:], hash.Sum(nil))
|
|
return result, nil
|
|
}
|