Cybersecurity-Projects/PROJECTS/beginner/simple-vulnerability-scanner/internal/pyproject/writer.go

161 lines
4.0 KiB
Go

/*
©AngelaMos | 2026
writer.go
Surgical in-place updater for pyproject.toml that preserves formatting
Uses regex replacement instead of TOML round-tripping to avoid rewriting
comments, whitespace, and key ordering. Validates the result is still
valid TOML after each update, then writes atomically via temp file and rename.
Key exports:
Updater - holds raw file bytes and applies per-dependency updates
NewUpdater - wraps raw bytes after validating TOML syntax
UpdateFile - convenience that reads, updates all deps, and writes back atomically
Connects to:
update.go - calls UpdateFile after resolving new versions
pypi/client.go - imports NormalizeName for case-insensitive package name matching
*/
package pyproject
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/CarterPerez-dev/angela/internal/pypi"
toml "github.com/pelletier/go-toml/v2"
)
// Updater performs surgical edits on raw pyproject.toml bytes, preserving
// all comments, whitespace, and key ordering
type Updater struct {
content []byte
}
// NewUpdater wraps raw file content after validating it as syntactically
// correct TOML
func NewUpdater(content []byte) (*Updater, error) {
var probe map[string]any
if err := toml.Unmarshal(content, &probe); err != nil {
return nil, fmt.Errorf("invalid TOML: %w", err)
}
return &Updater{content: content}, nil
}
// UpdateDependency replaces the version specifier for a single dependency
// while preserving quotes, extras, markers, and surrounding formatting.
// Tries double-quote patterns first, then single-quote.
func (u *Updater) UpdateDependency(pkg, newSpec string) error {
for _, q := range []byte{'"', '\''} {
pattern := buildDepPattern(pkg, q)
found := false
u.content = pattern.ReplaceAllFunc(
u.content,
func(match []byte) []byte {
found = true
return replaceSpec(pattern, match, newSpec, q)
},
)
if found {
var probe map[string]any
if err := toml.Unmarshal(u.content, &probe); err != nil {
return fmt.Errorf(
"update produced invalid TOML: %w", err,
)
}
return nil
}
}
return fmt.Errorf("dependency %q not found", pkg)
}
// Bytes returns the current file content
func (u *Updater) Bytes() []byte {
return u.content
}
// WriteFile atomically writes the updated content to disk
func (u *Updater) WriteFile(path string) error {
path = filepath.Clean(path)
tmp := path + ".tmp"
if err := os.WriteFile(tmp, u.content, 0o600); err != nil {
return fmt.Errorf("write temp: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp) //nolint:errcheck
return fmt.Errorf("rename: %w", err)
}
return nil
}
func buildDepPattern(name string, quote byte) *regexp.Regexp {
normalized := pypi.NormalizeName(name)
parts := strings.Split(normalized, "-")
for i, p := range parts {
parts[i] = regexp.QuoteMeta(p)
}
namePattern := strings.Join(parts, `[-_.]?`)
q := string(quote)
notQ := `[^` + q + `]`
return regexp.MustCompile(
`(?i)` +
q +
`(` + namePattern + `)` +
`(\[[^\]]*\])?` +
`(\s*[><=!~]` + notQ + `*?)` +
`(;` + notQ + `*)?` +
q,
)
}
func replaceSpec(
re *regexp.Regexp, match []byte, newSpec string, quote byte,
) []byte {
groups := re.FindSubmatch(match)
if len(groups) < 5 {
return match
}
name := groups[1]
extras := groups[2]
markers := groups[4]
var b []byte
b = append(b, quote)
b = append(b, name...)
b = append(b, extras...)
b = append(b, []byte(newSpec)...)
b = append(b, markers...)
b = append(b, quote)
return b
}
// UpdateFile is a convenience that reads, updates all given dependencies,
// and writes back atomically
func UpdateFile(path string, updates map[string]string) error {
data, err := os.ReadFile(path) //nolint:gosec
if err != nil {
return fmt.Errorf("read %s: %w", path, err)
}
u, err := NewUpdater(data)
if err != nil {
return err
}
for pkg, spec := range updates {
if err := u.UpdateDependency(pkg, spec); err != nil {
return fmt.Errorf("update %s: %w", pkg, err)
}
}
return u.WriteFile(path)
}