Initial commit on Forgejo
Fresh repository history for elektrine/tarakan-client hosted at https://git.elektrine.com/elektrine/tarakan-client.
This commit is contained in:
commit
58b29a7f84
70 changed files with 12994 additions and 0 deletions
240
internal/snapshot/snapshot.go
Normal file
240
internal/snapshot/snapshot.go
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
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
|
||||
}
|
||||
140
internal/snapshot/snapshot_test.go
Normal file
140
internal/snapshot/snapshot_test.go
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
package snapshot
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCreatePinsCommitWithoutGitMetadataAndDetectsChanges(t *testing.T) {
|
||||
repository := createTestRepository(t)
|
||||
commit := gitOutput(t, repository, "rev-parse", "HEAD")
|
||||
|
||||
snapshot, err := Create(repository, commit)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := snapshot.Root
|
||||
t.Cleanup(func() { _ = snapshot.Close() })
|
||||
|
||||
if _, err := os.Stat(filepath.Join(root, ".git")); !os.IsNotExist(err) {
|
||||
t.Fatalf("snapshot retains Git metadata: %v", err)
|
||||
}
|
||||
changed, err := snapshot.Changed()
|
||||
if err != nil || changed {
|
||||
t.Fatalf("unchanged snapshot: changed=%v err=%v", changed, err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("changed\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
changed, err = snapshot.Changed()
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("changed snapshot: changed=%v err=%v", changed, err)
|
||||
}
|
||||
|
||||
if err := snapshot.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(root); !os.IsNotExist(err) {
|
||||
t.Fatalf("snapshot was not destroyed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRejectsUnavailableCommit(t *testing.T) {
|
||||
repository := createTestRepository(t)
|
||||
_, err := Create(repository, "dead000000000000000000000000000000000000")
|
||||
if !errors.Is(err, ErrCommitUnavailable) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateNeutralizesExternalAndEscapingSymlinks(t *testing.T) {
|
||||
repository := createTestRepository(t)
|
||||
|
||||
// Absolute external pointer (common "works on my Mac" vendor path).
|
||||
if err := os.Symlink("/Users/someone/Desktop/secret", filepath.Join(repository, "external-abs")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Relative path that escapes the tree.
|
||||
if err := os.Symlink("../host-secret", filepath.Join(repository, "escape")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Safe in-tree relative link must be kept.
|
||||
if err := os.Symlink("README.md", filepath.Join(repository, "readme-link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runGitTest(t, repository, "add", "external-abs", "escape", "readme-link")
|
||||
runGitTest(t, repository, "-c", "user.name=Tarakan Test", "-c", "user.email=test@tarakan.invalid", "commit", "-m", "symlinks")
|
||||
|
||||
commit := gitOutput(t, repository, "rev-parse", "HEAD")
|
||||
snap, err := Create(repository, commit)
|
||||
if err != nil {
|
||||
t.Fatalf("Create should neutralize external symlinks, got: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = snap.Close() })
|
||||
|
||||
// Escaping / absolute links become ordinary files with the original target.
|
||||
for _, name := range []string{"external-abs", "escape"} {
|
||||
info, err := os.Lstat(filepath.Join(snap.Root, name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
t.Fatalf("%s still a symlink", name)
|
||||
}
|
||||
body, err := os.ReadFile(filepath.Join(snap.Root, name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(body), "neutralized external symlink") {
|
||||
t.Fatalf("%s body = %q", name, body)
|
||||
}
|
||||
}
|
||||
|
||||
// In-tree relative symlink preserved.
|
||||
target, err := os.Readlink(filepath.Join(snap.Root, "readme-link"))
|
||||
if err != nil {
|
||||
t.Fatalf("safe symlink should remain: %v", err)
|
||||
}
|
||||
if target != "README.md" {
|
||||
t.Fatalf("safe symlink target = %q", target)
|
||||
}
|
||||
|
||||
changed, err := snap.Changed()
|
||||
if err != nil || changed {
|
||||
t.Fatalf("unchanged after neutralize: changed=%v err=%v", changed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func createTestRepository(t *testing.T) string {
|
||||
t.Helper()
|
||||
repository := t.TempDir()
|
||||
runGitTest(t, repository, "init", "-b", "main")
|
||||
if err := os.WriteFile(filepath.Join(repository, "README.md"), []byte("pinned\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runGitTest(t, repository, "add", "README.md")
|
||||
runGitTest(t, repository, "-c", "user.name=Tarakan Test", "-c", "user.email=test@tarakan.invalid", "commit", "-m", "initial")
|
||||
return repository
|
||||
}
|
||||
|
||||
func runGitTest(t *testing.T, directory string, arguments ...string) {
|
||||
t.Helper()
|
||||
command := exec.Command("git", append([]string{"-C", directory}, arguments...)...)
|
||||
if output, err := command.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v: %v: %s", arguments, err, output)
|
||||
}
|
||||
}
|
||||
|
||||
func gitOutput(t *testing.T, directory string, arguments ...string) string {
|
||||
t.Helper()
|
||||
command := exec.Command("git", append([]string{"-C", directory}, arguments...)...)
|
||||
output, err := command.Output()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v: %v", arguments, err)
|
||||
}
|
||||
return strings.TrimSpace(string(output))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue