Close out the repository audit
This commit is contained in:
@@ -4,6 +4,7 @@ package promptdebug
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
@@ -14,6 +15,11 @@ import (
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
)
|
||||
|
||||
// ErrSecureCaptureUnsupported reports that the host cannot provide the
|
||||
// handle-relative, no-follow filesystem operations required for prompt debug
|
||||
// artifacts.
|
||||
var ErrSecureCaptureUnsupported = errors.New("secure prompt debug capture is unavailable on this platform")
|
||||
|
||||
const (
|
||||
promptPreparationDebugSchemaVersion = "weatherreporter.prompt_preparation_debug.v2"
|
||||
promptExecutionDebugSchemaVersion = "weatherreporter.prompt_execution_debug.v2"
|
||||
|
||||
28
internal/promptdebug/debug_writer_other_test.go
Normal file
28
internal/promptdebug/debug_writer_other_test.go
Normal file
@@ -0,0 +1,28 @@
|
||||
//go:build !unix
|
||||
|
||||
package promptdebug
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPromptDebugWriterFailsClosedWithoutSecureTraversal(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "debug")
|
||||
writer, err := NewPromptDebugWriter(root)
|
||||
if writer != nil || !errors.Is(err, ErrSecureCaptureUnsupported) {
|
||||
t.Fatalf("NewPromptDebugWriter() = %#v, %v, want unsupported error", writer, err)
|
||||
}
|
||||
if _, statErr := os.Stat(root); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("prompt debug root was accessed: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabledPromptDebugWriterRemainsPortable(t *testing.T) {
|
||||
writer, err := NewPromptDebugWriter("")
|
||||
if err != nil || writer == nil || writer.Enabled() {
|
||||
t.Fatalf("NewPromptDebugWriter(empty) = %#v, %v", writer, err)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
//go:build unix
|
||||
|
||||
package promptdebug
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/testutil"
|
||||
)
|
||||
|
||||
func TestPromptDebugWriterWritesIsolatedArtifacts(t *testing.T) {
|
||||
@@ -58,12 +60,10 @@ func TestPromptDebugWriterWritesIsolatedArtifacts(t *testing.T) {
|
||||
t.Fatalf("debug artifact contains credentials:\n%s", data)
|
||||
}
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
assertPromptDebugMode(t, root, debugDirectoryMode)
|
||||
assertPromptDebugMode(t, preparationDir, debugDirectoryMode)
|
||||
assertPromptDebugMode(t, filepath.Join(preparationDir, "preparation.json"), debugFileMode)
|
||||
assertPromptDebugMode(t, filepath.Join(executionDir, "execution.json"), debugFileMode)
|
||||
}
|
||||
assertPromptDebugMode(t, root, debugDirectoryMode)
|
||||
assertPromptDebugMode(t, preparationDir, debugDirectoryMode)
|
||||
assertPromptDebugMode(t, filepath.Join(preparationDir, "preparation.json"), debugFileMode)
|
||||
assertPromptDebugMode(t, filepath.Join(executionDir, "execution.json"), debugFileMode)
|
||||
}
|
||||
|
||||
func TestPromptDebugWriterProjectsProviderConfigurationSafely(t *testing.T) {
|
||||
@@ -222,21 +222,16 @@ func TestPromptDebugWriterCreatesSharedMissingAncestorsConcurrently(t *testing.T
|
||||
if len(directories) != writerCount {
|
||||
t.Fatalf("debug directories = %#v", directories)
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
assertPromptDebugMode(t, root, debugDirectoryMode)
|
||||
assertPromptDebugMode(t, filepath.Join(root, "daily"), debugDirectoryMode)
|
||||
assertPromptDebugMode(t, filepath.Join(root, "daily", "2026-05-29"), debugDirectoryMode)
|
||||
for directory := range directories {
|
||||
assertPromptDebugMode(t, directory, debugDirectoryMode)
|
||||
assertPromptDebugMode(t, filepath.Join(directory, "preparation.json"), debugFileMode)
|
||||
}
|
||||
assertPromptDebugMode(t, root, debugDirectoryMode)
|
||||
assertPromptDebugMode(t, filepath.Join(root, "daily"), debugDirectoryMode)
|
||||
assertPromptDebugMode(t, filepath.Join(root, "daily", "2026-05-29"), debugDirectoryMode)
|
||||
for directory := range directories {
|
||||
assertPromptDebugMode(t, directory, debugDirectoryMode)
|
||||
assertPromptDebugMode(t, filepath.Join(directory, "preparation.json"), debugFileMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptDebugWriterKeepsWritesAnchoredToOpenedRoot(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("directory replacement behavior is covered on Unix hosts")
|
||||
}
|
||||
parent := t.TempDir()
|
||||
root := filepath.Join(parent, "debug")
|
||||
writer, err := NewPromptDebugWriter(root)
|
||||
@@ -253,7 +248,7 @@ func TestPromptDebugWriterKeepsWritesAnchoredToOpenedRoot(t *testing.T) {
|
||||
if err := os.Rename(root, anchoredRoot); err != nil {
|
||||
t.Fatalf("replace opened root: %v", err)
|
||||
}
|
||||
requireSymlink(t, outside, root)
|
||||
testutil.RequireSymlink(t, outside, root)
|
||||
|
||||
ref := promptDebugRef()
|
||||
ref.RunID = "run-anchored"
|
||||
@@ -299,16 +294,14 @@ func TestPromptDebugWriterRejectsUnsafeRootsAndReferences(t *testing.T) {
|
||||
if _, err := NewPromptDebugWriter(nonDirectory); err == nil {
|
||||
t.Fatal("NewPromptDebugWriter(file) error = nil")
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
target := filepath.Join(root, "target")
|
||||
link := filepath.Join(root, "root-link")
|
||||
if err := os.Mkdir(target, debugDirectoryMode); err != nil {
|
||||
t.Fatalf("create root symlink target: %v", err)
|
||||
}
|
||||
requireSymlink(t, target, link)
|
||||
if _, err := NewPromptDebugWriter(link); err == nil {
|
||||
t.Fatal("NewPromptDebugWriter(symlink) error = nil")
|
||||
}
|
||||
target := filepath.Join(root, "target")
|
||||
link := filepath.Join(root, "root-link")
|
||||
if err := os.Mkdir(target, debugDirectoryMode); err != nil {
|
||||
t.Fatalf("create root symlink target: %v", err)
|
||||
}
|
||||
testutil.RequireSymlink(t, target, link)
|
||||
if _, err := NewPromptDebugWriter(link); err == nil {
|
||||
t.Fatal("NewPromptDebugWriter(symlink) error = nil")
|
||||
}
|
||||
|
||||
writer, err := NewPromptDebugWriter(filepath.Join(root, "debug"))
|
||||
@@ -325,22 +318,13 @@ func TestPromptDebugWriterRejectsUnsafeRootsAndReferences(t *testing.T) {
|
||||
t.Fatalf("WritePreparation(%#v) error = nil", ref)
|
||||
}
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
outside := filepath.Join(root, "outside")
|
||||
if err := os.Mkdir(outside, debugDirectoryMode); err != nil {
|
||||
t.Fatalf("create symlink component target: %v", err)
|
||||
}
|
||||
requireSymlink(t, outside, filepath.Join(root, "debug", "daily"))
|
||||
if _, err := writer.WritePreparation(promptDebugRef(), promptDebugPreparationFixture(), nil); err == nil {
|
||||
t.Fatal("WritePreparation(symlink component) error = nil")
|
||||
}
|
||||
outside := filepath.Join(root, "outside")
|
||||
if err := os.Mkdir(outside, debugDirectoryMode); err != nil {
|
||||
t.Fatalf("create symlink component target: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func requireSymlink(t *testing.T, target string, link string) {
|
||||
t.Helper()
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Skipf("symlink support is unavailable: %v", err)
|
||||
testutil.RequireSymlink(t, outside, filepath.Join(root, "debug", "daily"))
|
||||
if _, err := writer.WritePreparation(promptDebugRef(), promptDebugPreparationFixture(), nil); err == nil {
|
||||
t.Fatal("WritePreparation(symlink component) error = nil")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,141 +3,29 @@
|
||||
package promptdebug
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type secureDirectory struct {
|
||||
root *os.Root
|
||||
}
|
||||
// secureDirectory has no enabled implementation on platforms that cannot
|
||||
// provide handle-relative, no-follow directory traversal.
|
||||
type secureDirectory struct{}
|
||||
|
||||
func openSecureDirectory(path string) (*secureDirectory, error) {
|
||||
if !filepath.IsAbs(path) {
|
||||
return nil, fmt.Errorf("directory must be absolute")
|
||||
}
|
||||
if err := os.MkdirAll(path, debugDirectoryMode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||
return nil, fmt.Errorf("prompt debug root is not a directory")
|
||||
}
|
||||
if err := os.Chmod(path, debugDirectoryMode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
root, err := os.OpenRoot(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &secureDirectory{root: root}, nil
|
||||
return nil, ErrSecureCaptureUnsupported
|
||||
}
|
||||
|
||||
func (directory *secureDirectory) Close() error {
|
||||
if directory == nil || directory.root == nil {
|
||||
return nil
|
||||
}
|
||||
root := directory.root
|
||||
directory.root = nil
|
||||
return root.Close()
|
||||
}
|
||||
|
||||
func (directory *secureDirectory) openDirectory(components ...string) (*secureDirectory, error) {
|
||||
if directory == nil || directory.root == nil {
|
||||
return nil, fmt.Errorf("prompt debug directory is closed")
|
||||
}
|
||||
for _, component := range components {
|
||||
if err := validateSecureDirectoryName(component); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
path := filepath.Join(components...)
|
||||
if err := directory.root.MkdirAll(path, debugDirectoryMode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
child, err := directory.root.OpenRoot(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &secureDirectory{root: child}, nil
|
||||
}
|
||||
|
||||
func validateSecureDirectoryName(name string) error {
|
||||
if name == "" || name == "." || name == ".." || strings.ContainsRune(name, filepath.Separator) {
|
||||
return fmt.Errorf("prompt debug directory component %q is invalid", name)
|
||||
}
|
||||
func (*secureDirectory) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (directory *secureDirectory) writeJSON(name string, value any) error {
|
||||
if directory == nil || directory.root == nil {
|
||||
return fmt.Errorf("prompt debug directory is closed")
|
||||
}
|
||||
if err := validateSecureDirectoryName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal prompt debug artifact: %w", err)
|
||||
}
|
||||
if info, err := directory.root.Lstat(name); err == nil {
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("prompt debug file %q is not a regular file", name)
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
temporaryName, temporary, err := directory.createTemporaryFile(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if temporary != nil {
|
||||
_ = temporary.Close()
|
||||
}
|
||||
_ = directory.root.Remove(temporaryName)
|
||||
}()
|
||||
if err := temporary.Chmod(debugFileMode); err != nil {
|
||||
return err
|
||||
}
|
||||
if written, err := temporary.Write(data); err != nil {
|
||||
return err
|
||||
} else if written != len(data) {
|
||||
return io.ErrShortWrite
|
||||
}
|
||||
if err := temporary.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
temporary = nil
|
||||
if err := directory.root.Rename(temporaryName, name); err != nil {
|
||||
return fmt.Errorf("replace prompt debug file %q: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
func (*secureDirectory) openDirectory(...string) (*secureDirectory, error) {
|
||||
return nil, ErrSecureCaptureUnsupported
|
||||
}
|
||||
|
||||
func (directory *secureDirectory) createTemporaryFile(name string) (string, *os.File, error) {
|
||||
for attempt := 0; attempt < 16; attempt++ {
|
||||
random := make([]byte, 12)
|
||||
if _, err := rand.Read(random); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
temporaryName := "." + name + "." + hex.EncodeToString(random) + ".tmp"
|
||||
temporary, err := directory.root.OpenFile(temporaryName, os.O_WRONLY|os.O_CREATE|os.O_EXCL, debugFileMode)
|
||||
if os.IsExist(err) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return temporaryName, temporary, nil
|
||||
}
|
||||
return "", nil, fmt.Errorf("create temporary prompt debug file: too many name collisions")
|
||||
func (*secureDirectory) writeJSON(string, any) error {
|
||||
return ErrSecureCaptureUnsupported
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user