diff --git a/Makefile b/Makefile index e58e7a8..8e5111e 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,19 @@ # Minimal Makefile -- DX shortcut for the NOTICE-file generator. # Add other targets here as the project grows. -.PHONY: notice notice-check +.PHONY: notice notice-check go-build go-test go-vet + +# Build the Go binary. +go-build: + go build -o bin/apm-go ./cmd/apm + +# Run Go tests. +go-test: + go test ./... + +# Run Go vet. +go-vet: + go vet ./... # Regenerate NOTICE from pyproject.toml + scripts/notice-metadata.yaml. # Run this whenever you add / remove / bump a runtime dependency. diff --git a/benchmarks/migration-status.json b/benchmarks/migration-status.json new file mode 100644 index 0000000..1a19711 --- /dev/null +++ b/benchmarks/migration-status.json @@ -0,0 +1,176 @@ +{ + "original_python_lines": 71696, + "migrated_python_lines": 4245, + "migrated_modules": [ + { + "module": "src/apm_cli/constants.py", + "go_package": "internal/constants", + "python_lines": 55, + "status": "migrated", + "notes": "Pure constants and enum - no external dependencies" + }, + { + "module": "src/apm_cli/version.py", + "go_package": "internal/version", + "python_lines": 101, + "status": "migrated", + "notes": "Version resolution from build constants or pyproject.toml" + }, + { + "module": "src/apm_cli/utils/short_sha.py", + "go_package": "internal/utils/sha", + "python_lines": 45, + "status": "migrated", + "notes": "Short SHA formatter with sentinel and hex validation" + }, + { + "module": "src/apm_cli/utils/paths.py", + "go_package": "internal/utils/paths", + "python_lines": 27, + "status": "migrated", + "notes": "Cross-platform relative path utility" + }, + { + "module": "src/apm_cli/utils/normalization.py", + "go_package": "internal/utils/normalization", + "python_lines": 57, + "status": "migrated", + "notes": "Content normalization: BOM, CRLF, build-ID header stripping" + }, + { + "module": "src/apm_cli/utils/yaml_io.py", + "go_package": "internal/utils/yamlio", + "python_lines": 55, + "status": "migrated", + "notes": "YAML I/O with UTF-8; stdlib-only implementation" + }, + { + "module": "src/apm_cli/utils/atomic_io.py", + "go_package": "internal/utils/atomicio", + "python_lines": 52, + "status": "migrated", + "notes": "Atomic file write via temp+rename, same-filesystem rename" + }, + { + "module": "src/apm_cli/utils/git_env.py", + "go_package": "internal/utils/gitenv", + "python_lines": 97, + "status": "migrated", + "notes": "Cached git lookup and subprocess env sanitization" + }, + { + "module": "src/apm_cli/utils/guards.py", + "go_package": "internal/utils/guards", + "python_lines": 123, + "status": "migrated", + "notes": "ReadOnlyProjectGuard with snapshot-based mutation detection" + }, + { + "module": "src/apm_cli/utils/subprocess_env.py", + "go_package": "internal/utils/subprocenv", + "python_lines": 84, + "status": "migrated", + "notes": "PyInstaller env restoration; stdlib-only; MapToSlice helper" + }, + { + "module": "src/apm_cli/utils/helpers.py", + "go_package": "internal/utils/helpers", + "python_lines": 131, + "status": "migrated", + "notes": "IsToolAvailable, GetAvailablePackageManagers, DetectPlatform, FindPluginJSON" + }, + { + "module": "src/apm_cli/utils/content_hash.py", + "go_package": "internal/utils/contenthash", + "python_lines": 108, + "status": "migrated", + "notes": "Deterministic SHA-256 tree hashing; excludes .apm-pin marker and .git/__pycache__" + }, + { + "module": "src/apm_cli/utils/exclude.py", + "go_package": "internal/utils/exclude", + "python_lines": 169, + "status": "migrated", + "notes": "Glob pattern matching with ** support; bounded recursion; safety limit on ** count" + }, + { + "module": "src/apm_cli/utils/path_security.py", + "go_package": "internal/utils/pathsecurity", + "python_lines": 130, + "status": "migrated", + "notes": "Path traversal guards; iterative percent-decode; EnsurePathWithin; SafeRmtree" + }, + { + "module": "src/apm_cli/utils/version_checker.py", + "go_package": "internal/utils/versionchecker", + "python_lines": 193, + "status": "migrated", + "notes": "GitHub API version check; parse_version; is_newer_version; once-per-day cache" + }, + { + "module": "src/apm_cli/utils/file_ops.py", + "go_package": "internal/utils/fileops", + "python_lines": 326, + "status": "migrated", + "notes": "Retry-aware rmtree/copytree/copy2; exponential backoff; Windows AV-lock detection" + }, + { + "module": "src/apm_cli/utils/console.py", + "go_package": "internal/utils/console", + "python_lines": 224, + "status": "migrated", + "notes": "STATUS_SYMBOLS; RichEcho/Success/Error/Warning/Info; ANSI colour with NO_COLOR guard" + }, + { + "module": "src/apm_cli/utils/diagnostics.py", + "go_package": "internal/utils/diagnostics", + "python_lines": 486, + "status": "migrated", + "notes": "DiagnosticCollector; thread-safe; grouped RenderSummary; all category constants" + }, + { + "module": "src/apm_cli/utils/install_tui.py", + "go_package": "internal/utils/installtui", + "python_lines": 365, + "status": "migrated", + "notes": "InstallTui; deferred spinner (250ms); ShouldAnimate TTY check; phase/task tracking" + }, + { + "module": "src/apm_cli/utils/github_host.py", + "go_package": "internal/utils/githubhost", + "python_lines": 624, + "status": "migrated", + "notes": "Host classification (github/ghes/ghe_com/gitlab/ado/artifactory); GHES precedence; FQDN validation" + }, + { + "module": "src/apm_cli/utils/reflink.py", + "go_package": "internal/utils/reflink", + "python_lines": 281, + "status": "migrated", + "notes": "CoW reflink via FICLONE ioctl (Linux); device capability cache; regularCopy fallback" + }, + { + "module": "src/apm_cli/install/errors.py", + "go_package": "internal/install/errors", + "python_lines": 113, + "status": "migrated", + "notes": "DirectDependencyError, AuthenticationError, FrozenInstallError, PolicyViolationError" + }, + { + "module": "src/apm_cli/install/cache_pin.py", + "go_package": "internal/install/cachepin", + "python_lines": 233, + "status": "migrated", + "notes": "WriteMarker (silent on failures); VerifyMarker (typed CachePinError); schema v1" + }, + { + "module": "src/apm_cli/install/context.py", + "go_package": "internal/install/installctx", + "python_lines": 166, + "status": "migrated", + "notes": "InstallContext dataclass -> Go struct; all maps/slices initialised in New()" + } + ], + "last_updated": "2026-05-13T00:52:00Z", + "iteration": 13 +} diff --git a/bin/apm-go b/bin/apm-go new file mode 100755 index 0000000..bbdc5fe Binary files /dev/null and b/bin/apm-go differ diff --git a/cmd/apm/main.go b/cmd/apm/main.go new file mode 100644 index 0000000..eea6560 --- /dev/null +++ b/cmd/apm/main.go @@ -0,0 +1,19 @@ +// Package main is the APM CLI Go entry point. +// This is a stub that will grow as more Python modules are migrated. +package main + +import ( + "fmt" + "os" + + "github.com/githubnext/apm/internal/version" +) + +func main() { + if len(os.Args) > 1 && os.Args[1] == "version" { + fmt.Println(version.GetVersion()) + return + } + fmt.Fprintln(os.Stderr, "apm-go: stub binary (migration in progress)") + os.Exit(1) +} diff --git a/internal/constants/constants.go b/internal/constants/constants.go new file mode 100644 index 0000000..80e1d5d --- /dev/null +++ b/internal/constants/constants.go @@ -0,0 +1,44 @@ +// Package constants defines shared constants for the APM CLI. +// Migrated from src/apm_cli/constants.py +package constants + +// InstallMode controls which dependency types are installed. +type InstallMode string + +const ( + InstallModeAll InstallMode = "all" + InstallModeAPM InstallMode = "apm" + InstallModeMCP InstallMode = "mcp" +) + +// File and directory names. +const ( + APMYMLFilename = "apm.yml" + APMLockFilename = "apm.lock" + APMModulesDir = "apm_modules" + APMDir = ".apm" + SkillMDFilename = "SKILL.md" + AgentsMDFilename = "AGENTS.md" + ClaudeMDFilename = "CLAUDE.md" + GitHubDir = ".github" + ClaudeDir = ".claude" + GitignoreFilename = ".gitignore" + APMModulesGitignorePattern = "apm_modules/" +) + +// DefaultSkipDirs lists directory names unconditionally skipped during +// primitive-file discovery. These never contain APM primitives and can +// be very large (e.g. node_modules, .git objects). +var DefaultSkipDirs = map[string]struct{}{ + ".git": {}, + "node_modules": {}, + "__pycache__": {}, + ".pytest_cache": {}, + ".venv": {}, + "venv": {}, + ".tox": {}, + "build": {}, + "dist": {}, + ".mypy_cache": {}, + "apm_modules": {}, +} diff --git a/internal/install/cachepin/cachepin.go b/internal/install/cachepin/cachepin.go new file mode 100644 index 0000000..c4c64a2 --- /dev/null +++ b/internal/install/cachepin/cachepin.go @@ -0,0 +1,98 @@ +// Package cachepin provides cache-pin marker functionality for drift-replay correctness. +// +// When apm install populates apm_modules/// from a specific lockfile +// pin, it drops a small JSON marker (.apm-pin) at the package root recording the +// resolved_commit that produced the cache contents. +// +// apm audit drift-replay verifies the marker matches the lockfile's resolved_commit +// BEFORE diffing. +// +// Schema (v1): +// +// {"schema_version": 1, "resolved_commit": ""} +package cachepin + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" +) + +// MarkerFilename is the name of the cache-pin marker file. +const MarkerFilename = ".apm-pin" + +// SchemaVersion is the current schema version. +const SchemaVersion = 1 + +// CachePinError is raised when the cache pin is missing, malformed, or stale. +type CachePinError struct { + Msg string +} + +func (e *CachePinError) Error() string { return e.Msg } + +// IsCachePinError reports whether err is a CachePinError. +func IsCachePinError(err error) bool { + var t *CachePinError + return errors.As(err, &t) +} + +type markerPayload struct { + SchemaVersion int `json:"schema_version"` + ResolvedCommit string `json:"resolved_commit"` +} + +// WriteMarker writes the cache-pin marker file to installPath. +// +// Idempotent: overwrites any prior marker. Failures are silent because +// they are non-fatal for apm install itself. +func WriteMarker(installPath, resolvedCommit string) { + info, err := os.Stat(installPath) + if err != nil || !info.IsDir() { + return + } + payload := markerPayload{SchemaVersion: SchemaVersion, ResolvedCommit: resolvedCommit} + data, err := json.Marshal(payload) + if err != nil { + return + } + markerPath := filepath.Join(installPath, MarkerFilename) + _ = os.WriteFile(markerPath, data, 0o644) +} + +// VerifyMarker verifies the marker at installPath matches expectedCommit. +// +// Returns CachePinError on any of: marker file absent, unreadable, malformed +// JSON, unsupported schema_version, missing resolved_commit field, or commit +// mismatch. +func VerifyMarker(installPath, expectedCommit string) error { + markerPath := filepath.Join(installPath, MarkerFilename) + data, err := os.ReadFile(markerPath) + if err != nil { + if os.IsNotExist(err) { + return &CachePinError{Msg: fmt.Sprintf("cache-pin marker missing at %s (run apm install to refresh)", installPath)} + } + return &CachePinError{Msg: fmt.Sprintf("cannot read cache-pin marker at %s: %v", markerPath, err)} + } + + var payload markerPayload + if err := json.Unmarshal(data, &payload); err != nil { + return &CachePinError{Msg: fmt.Sprintf("cache-pin marker at %s is malformed JSON: %v", markerPath, err)} + } + + if payload.SchemaVersion != SchemaVersion { + return &CachePinError{Msg: fmt.Sprintf("cache-pin marker at %s has unsupported schema_version %d (expected %d)", markerPath, payload.SchemaVersion, SchemaVersion)} + } + + if payload.ResolvedCommit == "" { + return &CachePinError{Msg: fmt.Sprintf("cache-pin marker at %s is missing resolved_commit field", markerPath)} + } + + if payload.ResolvedCommit != expectedCommit { + return &CachePinError{Msg: fmt.Sprintf("cache-pin marker mismatch at %s: marker=%s expected=%s (run apm install to refresh)", markerPath, payload.ResolvedCommit, expectedCommit)} + } + + return nil +} diff --git a/internal/install/errors/errors.go b/internal/install/errors/errors.go new file mode 100644 index 0000000..407a9e2 --- /dev/null +++ b/internal/install/errors/errors.go @@ -0,0 +1,99 @@ +// Package errors provides canonical exception types for the install pipeline. +// +// Centralises typed errors raised by the install machinery so call sites +// can handle a single class hierarchy. +package errors + +// DirectDependencyError is raised when one or more direct dependencies fail +// validation or integration. +type DirectDependencyError struct { + Msg string +} + +func (e *DirectDependencyError) Error() string { return e.Msg } + +// NewDirectDependencyError creates a DirectDependencyError. +func NewDirectDependencyError(msg string) *DirectDependencyError { + return &DirectDependencyError{Msg: msg} +} + +// AuthenticationError is raised when a remote host rejects credentials or +// none are available. +type AuthenticationError struct { + Msg string + DiagnosticContext string +} + +func (e *AuthenticationError) Error() string { return e.Msg } + +// NewAuthenticationError creates an AuthenticationError. +func NewAuthenticationError(msg, diagnosticContext string) *AuthenticationError { + return &AuthenticationError{Msg: msg, DiagnosticContext: diagnosticContext} +} + +// FrozenInstallError is raised when apm install --frozen cannot proceed. +// Two trigger conditions: +// - Lockfile (apm.lock.yaml) is missing entirely. +// - Lockfile is structurally out of sync with apm.yml. +type FrozenInstallError struct { + Msg string + Reasons []string +} + +func (e *FrozenInstallError) Error() string { return e.Msg } + +// NewFrozenInstallError creates a FrozenInstallError. +func NewFrozenInstallError(msg string, reasons []string) *FrozenInstallError { + r := make([]string, len(reasons)) + copy(r, reasons) + return &FrozenInstallError{Msg: msg, Reasons: r} +} + +// PolicyViolationError is raised when org-policy enforcement halts an install. +type PolicyViolationError struct { + Msg string + PolicySource string +} + +func (e *PolicyViolationError) Error() string { return e.Msg } + +// NewPolicyViolationError creates a PolicyViolationError. +func NewPolicyViolationError(msg, policySource string) *PolicyViolationError { + return &PolicyViolationError{Msg: msg, PolicySource: policySource} +} + +// IsDirect returns true if err is a DirectDependencyError. +func IsDirect(err error) bool { + if err == nil { + return false + } + _, ok := err.(*DirectDependencyError) + return ok +} + +// IsAuthentication returns true if err is an AuthenticationError. +func IsAuthentication(err error) bool { + if err == nil { + return false + } + _, ok := err.(*AuthenticationError) + return ok +} + +// IsFrozen returns true if err is a FrozenInstallError. +func IsFrozen(err error) bool { + if err == nil { + return false + } + _, ok := err.(*FrozenInstallError) + return ok +} + +// IsPolicy returns true if err is a PolicyViolationError. +func IsPolicy(err error) bool { + if err == nil { + return false + } + _, ok := err.(*PolicyViolationError) + return ok +} diff --git a/internal/install/installctx/installctx.go b/internal/install/installctx/installctx.go new file mode 100644 index 0000000..74af160 --- /dev/null +++ b/internal/install/installctx/installctx.go @@ -0,0 +1,111 @@ +// Package installctx provides the mutable state passed between install pipeline phases. +// +// Each phase is a function run(ctx *InstallContext) that reads inputs already +// populated by earlier phases and writes its own outputs to the context. +package installctx + +import ( + "path/filepath" + "sync" +) + +// InstallContext holds state shared across install pipeline phases. +// Fields are grouped by the phase that first populates them. +type InstallContext struct { + mu sync.RWMutex + + // Required on construction + ProjectRoot string + ApmDir string + + // Inputs: populated by the caller from CLI args / APMPackage + UpdateRefs bool + ParallelDownloads int + TargetOverride string + AllowInsecure bool + AllowInsecureHosts []string + DryRun bool + Force bool + Verbose bool + Dev bool + OnlyPackages []string + AllowProtocolFallback *bool // nil => read env + + // Resolve phase outputs + RootHasLocalPrimitives bool + LockfilePath string + ApmModulesDir string + InstalledCount int + UnpinnedCount int + + // Integrate phase outputs + IntendedDepKeys map[string]bool + PackageDeployedFiles map[string][]string + PackageTypes map[string]string + PackageHashes map[string]string + ExpectedHashChangeDeps map[string]bool + TotalPromptsIntegrated int + TotalAgentsIntegrated int + TotalSkillsIntegrated int + TotalSubSkillsPromoted int + TotalInstructionsIntegrated int + TotalCommandsIntegrated int + TotalHooksIntegrated int + TotalLinksResolved int + DirectDepFailed bool + + // Policy gate + PolicyEnforcementActive bool + NoPolicy bool + SkillSubset []string + SkillSubsetFromCLI bool + + // Local content tracking + OldLocalDeployed []string + LocalDeployedFiles []string + LocalContentErrorsBefore int + + // Cowork integration + CoworkNonsupportedWarned bool + + // Legacy opt-out + LegacySkillPaths bool +} + +// New creates an InstallContext with all maps and slices initialised. +func New(projectRoot, apmDir string) *InstallContext { + return &InstallContext{ + ProjectRoot: projectRoot, + ApmDir: apmDir, + ParallelDownloads: 4, + AllowInsecureHosts: make([]string, 0), + OnlyPackages: make([]string, 0), + IntendedDepKeys: make(map[string]bool), + PackageDeployedFiles: make(map[string][]string), + PackageTypes: make(map[string]string), + PackageHashes: make(map[string]string), + ExpectedHashChangeDeps: make(map[string]bool), + OldLocalDeployed: make([]string, 0), + LocalDeployedFiles: make([]string, 0), + } +} + +// ApmModulesDirOrDefault returns ApmModulesDir or the default path. +func (ctx *InstallContext) ApmModulesDirOrDefault() string { + ctx.mu.RLock() + defer ctx.mu.RUnlock() + if ctx.ApmModulesDir != "" { + return ctx.ApmModulesDir + } + return filepath.Join(ctx.ProjectRoot, "apm_modules") +} + +// LockfilePathOrDefault returns LockfilePath or the default path. +func (ctx *InstallContext) LockfilePathOrDefault() string { + ctx.mu.RLock() + defer ctx.mu.RUnlock() + if ctx.LockfilePath != "" { + return ctx.LockfilePath + } + return filepath.Join(ctx.ProjectRoot, "apm.lock.yaml") +} diff --git a/internal/utils/atomicio/atomicio.go b/internal/utils/atomicio/atomicio.go new file mode 100644 index 0000000..caa7b23 --- /dev/null +++ b/internal/utils/atomicio/atomicio.go @@ -0,0 +1,57 @@ +// Package atomicio provides atomic file-write primitives. +// Mirrors src/apm_cli/utils/atomic_io.py. +package atomicio + +import ( + "os" + "path/filepath" +) + +// WriteText atomically writes data (UTF-8) to path. +// The temp file is created in path's parent directory so the eventual +// os.Rename is a same-filesystem rename. If newFileMode > 0 and path +// does not yet exist, the temp file's mode bits are set to that value +// before the rename. On any failure, the temp file is removed and the +// original target file (if any) remains untouched. +func WriteText(path string, data string, newFileMode os.FileMode) error { + dir := filepath.Dir(path) + existed := fileExists(path) + + f, err := os.CreateTemp(dir, "apm-atomic-") + if err != nil { + return err + } + tmpName := f.Name() + + cleanup := func() { + f.Close() + os.Remove(tmpName) + } + + if newFileMode > 0 && !existed { + if err := f.Chmod(newFileMode); err != nil { + cleanup() + return err + } + } + + if _, err := f.WriteString(data); err != nil { + cleanup() + return err + } + if err := f.Close(); err != nil { + os.Remove(tmpName) + return err + } + + if err := os.Rename(tmpName, path); err != nil { + os.Remove(tmpName) + return err + } + return nil +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/internal/utils/atomicio/atomicio_test.go b/internal/utils/atomicio/atomicio_test.go new file mode 100644 index 0000000..8bd5c1d --- /dev/null +++ b/internal/utils/atomicio/atomicio_test.go @@ -0,0 +1,41 @@ +package atomicio_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/githubnext/apm/internal/utils/atomicio" +) + +func TestWriteText(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "out.txt") + + if err := atomicio.WriteText(path, "hello world", 0); err != nil { + t.Fatalf("WriteText: %v", err) + } + + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if string(got) != "hello world" { + t.Errorf("got %q, want %q", got, "hello world") + } +} + +func TestWriteTextOverwrite(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "out.txt") + + atomicio.WriteText(path, "first", 0) + if err := atomicio.WriteText(path, "second", 0); err != nil { + t.Fatalf("WriteText overwrite: %v", err) + } + + got, _ := os.ReadFile(path) + if string(got) != "second" { + t.Errorf("got %q, want second", got) + } +} diff --git a/internal/utils/diagnostics/diagnostics.go b/internal/utils/diagnostics/diagnostics.go new file mode 100644 index 0000000..eccb64c --- /dev/null +++ b/internal/utils/diagnostics/diagnostics.go @@ -0,0 +1,202 @@ +// Package diagnostics provides a collect-then-render diagnostic reporting system. +// +// Integrators push diagnostics during install (or any command), and the +// collector renders a clean, grouped summary at the end. Thread-safe. +package diagnostics + +import ( + "fmt" + "io" + "os" + "sync" +) + +// Category constants for diagnostic grouping. +const ( + CategoryCollision = "collision" + CategoryOverwrite = "overwrite" + CategoryWarning = "warning" + CategoryError = "error" + CategorySecurity = "security" + CategoryPolicy = "policy" + CategoryAuth = "auth" + CategoryDrift = "drift" + CategoryInfo = "info" +) + +// Drift severity constants. +const ( + DriftModified = "modified" + DriftUnintegrated = "unintegrated" + DriftOrphaned = "orphaned" +) + +var categoryOrder = []string{ + CategorySecurity, + CategoryPolicy, + CategoryAuth, + CategoryDrift, + CategoryCollision, + CategoryOverwrite, + CategoryWarning, + CategoryError, + CategoryInfo, +} + +// Diagnostic is a single diagnostic message produced during an operation. +type Diagnostic struct { + Message string + Category string + Package string + Detail string + Severity string // "critical", "warning", "info" -- used by security category +} + +// DiagnosticCollector collects diagnostics during a multi-package operation +// and renders a grouped summary at the end. Thread-safe. +type DiagnosticCollector struct { + verbose bool + diagnostics []Diagnostic + mu sync.Mutex + Out io.Writer +} + +// New creates a new DiagnosticCollector. +func New(verbose bool) *DiagnosticCollector { + return &DiagnosticCollector{verbose: verbose, Out: os.Stdout} +} + +// Skip records a collision skip (file exists, not managed by APM). +func (d *DiagnosticCollector) Skip(path, pkg string) { + d.add(Diagnostic{Message: path, Category: CategoryCollision, Package: pkg}) +} + +// Overwrite records a sub-skill or file overwrite. +func (d *DiagnosticCollector) Overwrite(path, pkg, detail string) { + d.add(Diagnostic{Message: path, Category: CategoryOverwrite, Package: pkg, Detail: detail}) +} + +// Warn records a general warning. +func (d *DiagnosticCollector) Warn(message, pkg, detail string) { + d.add(Diagnostic{Message: message, Category: CategoryWarning, Package: pkg, Detail: detail}) +} + +// Error records an error (download failure, integration failure, etc.). +func (d *DiagnosticCollector) Error(message, pkg, detail string) { + d.add(Diagnostic{Message: message, Category: CategoryError, Package: pkg, Detail: detail}) +} + +// Security records a security finding (hidden characters, etc.). +func (d *DiagnosticCollector) Security(message, pkg, detail, severity string) { + if severity == "" { + severity = "warning" + } + d.add(Diagnostic{Message: message, Category: CategorySecurity, Package: pkg, Detail: detail, Severity: severity}) +} + +// Info records an informational hint (non-blocking, actionable guidance). +func (d *DiagnosticCollector) Info(message, pkg, detail string) { + d.add(Diagnostic{Message: message, Category: CategoryInfo, Package: pkg, Detail: detail}) +} + +// Policy records a policy enforcement finding. +func (d *DiagnosticCollector) Policy(message, pkg, detail string) { + d.add(Diagnostic{Message: message, Category: CategoryPolicy, Package: pkg, Detail: detail}) +} + +// Auth records an authentication issue. +func (d *DiagnosticCollector) Auth(message, pkg, detail string) { + d.add(Diagnostic{Message: message, Category: CategoryAuth, Package: pkg, Detail: detail}) +} + +// Drift records a drift finding. +func (d *DiagnosticCollector) Drift(message, pkg, detail string) { + d.add(Diagnostic{Message: message, Category: CategoryDrift, Package: pkg, Detail: detail}) +} + +// HasDiagnostics returns true if any diagnostics have been recorded. +func (d *DiagnosticCollector) HasDiagnostics() bool { + d.mu.Lock() + defer d.mu.Unlock() + return len(d.diagnostics) > 0 +} + +// HasErrors returns true if any error diagnostics have been recorded. +func (d *DiagnosticCollector) HasErrors() bool { + d.mu.Lock() + defer d.mu.Unlock() + for _, diag := range d.diagnostics { + if diag.Category == CategoryError || diag.Category == CategorySecurity { + return true + } + } + return false +} + +// All returns a copy of all collected diagnostics. +func (d *DiagnosticCollector) All() []Diagnostic { + d.mu.Lock() + defer d.mu.Unlock() + result := make([]Diagnostic, len(d.diagnostics)) + copy(result, d.diagnostics) + return result +} + +// RenderSummary prints a grouped summary of all diagnostics. +func (d *DiagnosticCollector) RenderSummary() { + d.mu.Lock() + diags := make([]Diagnostic, len(d.diagnostics)) + copy(diags, d.diagnostics) + d.mu.Unlock() + + if len(diags) == 0 { + return + } + + // Group by category + grouped := make(map[string][]Diagnostic) + for _, diag := range diags { + grouped[diag.Category] = append(grouped[diag.Category], diag) + } + + headers := map[string]string{ + CategorySecurity: "[!] Security findings", + CategoryPolicy: "[!] Policy enforcement", + CategoryAuth: "[!] Authentication issues", + CategoryDrift: "[!] Drift detected", + CategoryCollision: "[!] File collisions (skipped)", + CategoryOverwrite: "[~] Overwrites", + CategoryWarning: "[!] Warnings", + CategoryError: "[x] Errors", + CategoryInfo: "[i] Notes", + } + + out := d.Out + for _, cat := range categoryOrder { + items, ok := grouped[cat] + if !ok || len(items) == 0 { + continue + } + header, ok := headers[cat] + if !ok { + header = "[i] " + cat + } + fmt.Fprintf(out, "\n%s (%d):\n", header, len(items)) + for _, item := range items { + line := " - " + item.Message + if item.Package != "" { + line += " [" + item.Package + "]" + } + if item.Detail != "" && d.verbose { + line += "\n " + item.Detail + } + fmt.Fprintln(out, line) + } + } +} + +func (d *DiagnosticCollector) add(diag Diagnostic) { + d.mu.Lock() + d.diagnostics = append(d.diagnostics, diag) + d.mu.Unlock() +} diff --git a/internal/utils/gitenv/gitenv.go b/internal/utils/gitenv/gitenv.go new file mode 100644 index 0000000..2156647 --- /dev/null +++ b/internal/utils/gitenv/gitenv.go @@ -0,0 +1,72 @@ +// Package gitenv provides cached git binary lookup and subprocess +// environment sanitization. Mirrors src/apm_cli/utils/git_env.py. +package gitenv + +import ( + "errors" + "os" + "os/exec" + "sync" +) + +// stripGitVars lists ambient git state variables that are stripped from +// subprocess environments to avoid biasing APM's git operations. +var stripGitVars = map[string]struct{}{ + "GIT_DIR": {}, + "GIT_WORK_TREE": {}, + "GIT_INDEX_FILE": {}, + "GIT_OBJECT_DIRECTORY": {}, + "GIT_ALTERNATE_OBJECT_DIRECTORIES": {}, + "GIT_COMMON_DIR": {}, + "GIT_NAMESPACE": {}, + "GIT_INDEX_VERSION": {}, + "GIT_CEILING_DIRECTORIES": {}, + "GIT_DISCOVERY_ACROSS_FILESYSTEM": {}, + "GIT_REPLACE_REF_BASE": {}, + "GIT_GRAFTS_FILE": {}, + "GIT_SHALLOW_FILE": {}, +} + +var ( + once sync.Once + gitExecutable string + gitErr error +) + +// GetGitExecutable returns the path to the git executable (cached after first lookup). +func GetGitExecutable() (string, error) { + once.Do(func() { + gitExecutable, gitErr = exec.LookPath("git") + if gitErr != nil { + gitErr = errors.New("git executable not found on PATH. Please install git: https://git-scm.com/downloads") + } + }) + return gitExecutable, gitErr +} + +// GitSubprocessEnv returns a sanitized environment slice for git subprocesses. +// Strips ambient git state variables while preserving user-controlled configuration. +func GitSubprocessEnv() []string { + env := os.Environ() + result := make([]string, 0, len(env)) + for _, kv := range env { + key := kv + for i, c := range kv { + if c == '=' { + key = kv[:i] + break + } + } + if _, strip := stripGitVars[key]; !strip { + result = append(result, kv) + } + } + return result +} + +// ResetGitCache resets the cached git executable (for testing purposes only). +func ResetGitCache() { + once = sync.Once{} + gitExecutable = "" + gitErr = nil +} diff --git a/internal/utils/gitenv/gitenv_test.go b/internal/utils/gitenv/gitenv_test.go new file mode 100644 index 0000000..c711917 --- /dev/null +++ b/internal/utils/gitenv/gitenv_test.go @@ -0,0 +1,34 @@ +package gitenv_test + +import ( + "testing" + + "github.com/githubnext/apm/internal/utils/gitenv" +) + +func TestGetGitExecutable(t *testing.T) { + gitenv.ResetGitCache() + path, err := gitenv.GetGitExecutable() + if err != nil { + t.Fatalf("GetGitExecutable: %v", err) + } + if path == "" { + t.Error("expected non-empty git path") + } +} + +func TestGitSubprocessEnv(t *testing.T) { + env := gitenv.GitSubprocessEnv() + if len(env) == 0 { + t.Error("expected non-empty env") + } + for _, kv := range env { + for _, stripped := range []string{ + "GIT_DIR=", "GIT_WORK_TREE=", "GIT_INDEX_FILE=", + } { + if len(kv) >= len(stripped) && kv[:len(stripped)] == stripped { + t.Errorf("env contains stripped var: %s", kv) + } + } + } +} diff --git a/internal/utils/githubhost/githubhost.go b/internal/utils/githubhost/githubhost.go new file mode 100644 index 0000000..b05670c --- /dev/null +++ b/internal/utils/githubhost/githubhost.go @@ -0,0 +1,222 @@ +// Package githubhost provides utilities for handling GitHub, GitHub Enterprise, +// Azure DevOps, and other Git host hostnames and URLs. +package githubhost + +import ( + "os" + "regexp" + "strings" +) + +// validFQDNRe matches a valid fully-qualified domain name. +var validFQDNRe = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$`) + +// DefaultHost returns the default Git host (can be overridden via GITHUB_HOST env var). +func DefaultHost() string { + if h := os.Getenv("GITHUB_HOST"); h != "" { + return h + } + return "github.com" +} + +// IsAzureDevOpsHostname returns true if hostname is Azure DevOps (cloud or server). +func IsAzureDevOpsHostname(hostname string) bool { + if hostname == "" { + return false + } + h := strings.ToLower(hostname) + return h == "dev.azure.com" || strings.HasSuffix(h, ".visualstudio.com") +} + +// IsVisualStudioLegacyHostname returns true if hostname is a legacy *.visualstudio.com ADO host. +func IsVisualStudioLegacyHostname(hostname string) bool { + if hostname == "" { + return false + } + return strings.HasSuffix(strings.ToLower(hostname), ".visualstudio.com") +} + +// IsGitLabHostname returns true if hostname is GitLab SaaS or a configured GitLab host. +func IsGitLabHostname(hostname string) bool { + if hostname == "" { + return false + } + h := normalizeHost(hostname) + + // GHES precedence: GITHUB_HOST match is enterprise GitHub, not GitLab + ghesHost := normalizeHost(os.Getenv("GITHUB_HOST")) + if ghesHost != "" && ghesHost == h && + ghesHost != "github.com" && ghesHost != "gitlab.com" && + !strings.HasSuffix(ghesHost, ".ghe.com") && + IsValidFQDN(ghesHost) { + return false + } + + if h == "gitlab.com" { + return true + } + gitlabSingle := normalizeHost(os.Getenv("GITLAB_HOST")) + if gitlabSingle != "" && gitlabSingle == h { + return IsValidFQDN(h) + } + rawList := os.Getenv("APM_GITLAB_HOSTS") + for _, part := range strings.Split(rawList, ",") { + entry := normalizeHost(part) + if entry != "" && entry == h && IsValidFQDN(entry) { + return true + } + } + return false +} + +// HasGitHubGitLabHostEnvConflict returns true when hostname is claimed as both GHES and GitLab. +func HasGitHubGitLabHostEnvConflict(hostname string) bool { + if hostname == "" { + return false + } + h := normalizeHost(hostname) + if !IsValidFQDN(h) { + return false + } + ghesHost := normalizeHost(os.Getenv("GITHUB_HOST")) + if ghesHost == "" || ghesHost != h || + ghesHost == "github.com" || ghesHost == "gitlab.com" || + strings.HasSuffix(ghesHost, ".ghe.com") { + return false + } + gitlabSingle := normalizeHost(os.Getenv("GITLAB_HOST")) + if gitlabSingle != "" && gitlabSingle == h { + return true + } + rawList := os.Getenv("APM_GITLAB_HOSTS") + for _, part := range strings.Split(rawList, ",") { + if normalizeHost(part) == h { + return true + } + } + return false +} + +// IsGHEHostname returns true if hostname is GitHub Enterprise Server or GHE.com. +func IsGHEHostname(hostname string) bool { + if hostname == "" { + return false + } + h := normalizeHost(hostname) + if h == "github.com" { + return false + } + if strings.HasSuffix(h, ".ghe.com") { + return true + } + ghesHost := normalizeHost(os.Getenv("GITHUB_HOST")) + return ghesHost != "" && ghesHost == h && IsValidFQDN(h) +} + +// IsGitHubHostname returns true if hostname is github.com or a GHES instance. +func IsGitHubHostname(hostname string) bool { + if hostname == "" { + return false + } + h := normalizeHost(hostname) + return h == "github.com" || IsGHEHostname(h) +} + +// IsArtifactoryHostname returns true if hostname is an Artifactory instance. +func IsArtifactoryHostname(hostname string) bool { + if hostname == "" { + return false + } + h := normalizeHost(hostname) + // Check APM_ARTIFACTORY_HOSTS env + rawList := os.Getenv("APM_ARTIFACTORY_HOSTS") + for _, part := range strings.Split(rawList, ",") { + entry := normalizeHost(part) + if entry != "" && entry == h { + return true + } + } + return false +} + +// ClassifyHost returns the host type: "github", "ghes", "ghe_com", "gitlab", +// "azure_devops", "artifactory", or "unknown". +func ClassifyHost(hostname string) string { + if hostname == "" { + return "unknown" + } + h := normalizeHost(hostname) + if h == "github.com" { + return "github" + } + if strings.HasSuffix(h, ".ghe.com") { + return "ghe_com" + } + ghesHost := normalizeHost(os.Getenv("GITHUB_HOST")) + if ghesHost != "" && ghesHost == h && ghesHost != "github.com" && IsValidFQDN(h) { + return "ghes" + } + if IsAzureDevOpsHostname(h) { + return "azure_devops" + } + if IsGitLabHostname(h) { + return "gitlab" + } + if IsArtifactoryHostname(h) { + return "artifactory" + } + return "unknown" +} + +// IsValidFQDN returns true if hostname is a syntactically valid fully-qualified domain name. +func IsValidFQDN(hostname string) bool { + if hostname == "" || len(hostname) > 253 { + return false + } + return validFQDNRe.MatchString(hostname) +} + +// ParseHostFromURL extracts the hostname from a URL string. +func ParseHostFromURL(rawURL string) string { + // Strip scheme + s := rawURL + if idx := strings.Index(s, "://"); idx >= 0 { + s = s[idx+3:] + } + // Strip path + if idx := strings.Index(s, "/"); idx >= 0 { + s = s[:idx] + } + // Strip port + if idx := strings.LastIndex(s, ":"); idx >= 0 { + s = s[:idx] + } + // Strip user info + if idx := strings.Index(s, "@"); idx >= 0 { + s = s[idx+1:] + } + return strings.ToLower(strings.TrimSpace(s)) +} + +// AzureDevOpsOrgFromHostname extracts the org name from a legacy *.visualstudio.com host. +func AzureDevOpsOrgFromHostname(hostname string) string { + h := strings.ToLower(hostname) + if !strings.HasSuffix(h, ".visualstudio.com") { + return "" + } + parts := strings.SplitN(h, ".", 2) + if len(parts) == 0 { + return "" + } + return parts[0] +} + +func normalizeHost(s string) string { + s = strings.TrimSpace(s) + s = strings.ToLower(s) + // Strip path component + if idx := strings.Index(s, "/"); idx >= 0 { + s = s[:idx] + } + return s +} diff --git a/internal/utils/githubhost/githubhost_test.go b/internal/utils/githubhost/githubhost_test.go new file mode 100644 index 0000000..d4f1e03 --- /dev/null +++ b/internal/utils/githubhost/githubhost_test.go @@ -0,0 +1,71 @@ +package githubhost_test + +import ( + "os" + "testing" + + "github.com/githubnext/apm/internal/utils/githubhost" +) + +func TestDefaultHost(t *testing.T) { + os.Unsetenv("GITHUB_HOST") + if got := githubhost.DefaultHost(); got != "github.com" { + t.Errorf("want github.com got %s", got) + } + os.Setenv("GITHUB_HOST", "myghe.example.com") + if got := githubhost.DefaultHost(); got != "myghe.example.com" { + t.Errorf("want myghe.example.com got %s", got) + } + os.Unsetenv("GITHUB_HOST") +} + +func TestIsAzureDevOpsHostname(t *testing.T) { + tests := []struct{ h string; want bool }{ + {"dev.azure.com", true}, + {"myorg.visualstudio.com", true}, + {"github.com", false}, + {"", false}, + } + for _, tt := range tests { + if got := githubhost.IsAzureDevOpsHostname(tt.h); got != tt.want { + t.Errorf("IsAzureDevOpsHostname(%q)=%v want %v", tt.h, got, tt.want) + } + } +} + +func TestIsValidFQDN(t *testing.T) { + tests := []struct{ h string; want bool }{ + {"github.com", true}, + {"myghe.example.com", true}, + {"localhost", false}, + {"", false}, + {"not valid!", false}, + } + for _, tt := range tests { + if got := githubhost.IsValidFQDN(tt.h); got != tt.want { + t.Errorf("IsValidFQDN(%q)=%v want %v", tt.h, got, tt.want) + } + } +} + +func TestClassifyHost(t *testing.T) { + os.Unsetenv("GITHUB_HOST") + os.Unsetenv("GITLAB_HOST") + os.Unsetenv("APM_GITLAB_HOSTS") + + if got := githubhost.ClassifyHost("github.com"); got != "github" { + t.Errorf("want github got %s", got) + } + if got := githubhost.ClassifyHost("myorg.ghe.com"); got != "ghe_com" { + t.Errorf("want ghe_com got %s", got) + } + if got := githubhost.ClassifyHost("dev.azure.com"); got != "azure_devops" { + t.Errorf("want azure_devops got %s", got) + } + + os.Setenv("GITLAB_HOST", "gitlab.mycompany.com") + if got := githubhost.ClassifyHost("gitlab.mycompany.com"); got != "gitlab" { + t.Errorf("want gitlab got %s", got) + } + os.Unsetenv("GITLAB_HOST") +} diff --git a/internal/utils/guards/guards.go b/internal/utils/guards/guards.go new file mode 100644 index 0000000..df5f601 --- /dev/null +++ b/internal/utils/guards/guards.go @@ -0,0 +1,163 @@ +// Package guards provides a read-only project-tree guard for drift detection. +// +// When apm audit runs the install pipeline against a scratch directory to +// compute drift, the working tree must remain untouched. ReadOnlyProjectGuard +// takes a stat snapshot of every protected path on entry and asserts no +// mutation occurred on exit. Any divergence returns a ProtectedPathMutationError. +// +// This is a defense-in-depth check: the primary mechanism is redirecting all +// writes via project_root=scratch_root. The guard catches accidental +// direct-path writes that bypass the redirection. +package guards + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +// ProtectedPathMutationError is returned when a path under guard was mutated. +type ProtectedPathMutationError struct { + Violations []string +} + +func (e *ProtectedPathMutationError) Error() string { + return "Drift replay mutated protected project paths:\n - " + + strings.Join(e.Violations, "\n - ") +} + +type fileInfo struct { + mtimeNs int64 + size int64 + exists bool +} + +func statFile(path string) fileInfo { + fi, err := os.Stat(path) + if err != nil { + return fileInfo{exists: false} + } + return fileInfo{ + mtimeNs: fi.ModTime().UnixNano(), + size: fi.Size(), + exists: true, + } +} + +// walkProtected enumerates every regular file under each root (recursive). +// Missing roots are silently dropped. Symlinks are not followed. +func walkProtected(roots []string) []string { + var files []string + for _, root := range roots { + fi, err := os.Lstat(root) + if err != nil { + continue + } + if fi.Mode().IsRegular() { + files = append(files, root) + continue + } + if fi.IsDir() { + _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil + } + if d.Type()&os.ModeSymlink != 0 { + if d.IsDir() { + return filepath.SkipDir + } + return nil + } + if d.Type().IsRegular() { + files = append(files, path) + } + return nil + }) + } + } + return files +} + +// ReadOnlyProjectGuard snapshots protected paths and asserts no mutation. +// +// Usage: +// +// g := NewReadOnlyProjectGuard(projectRoot, []string{".apm", "apm.lock.yaml", ".github"}) +// if err := g.Enter(); err != nil { ... } +// runReplay(...) +// if err := g.Exit(nil); err != nil { ... } +type ReadOnlyProjectGuard struct { + projectRoot string + protectedRoots []string + snapshot map[string]fileInfo +} + +// NewReadOnlyProjectGuard creates a new guard. +func NewReadOnlyProjectGuard(projectRoot string, protectedSubpaths []string) *ReadOnlyProjectGuard { + abs, _ := filepath.Abs(projectRoot) + roots := make([]string, len(protectedSubpaths)) + for i, sp := range protectedSubpaths { + roots[i] = filepath.Join(abs, sp) + } + return &ReadOnlyProjectGuard{ + projectRoot: abs, + protectedRoots: roots, + snapshot: make(map[string]fileInfo), + } +} + +// Enter takes the initial snapshot of protected paths. +func (g *ReadOnlyProjectGuard) Enter() error { + files := walkProtected(g.protectedRoots) + for _, f := range files { + g.snapshot[f] = statFile(f) + } + return nil +} + +// Exit checks for mutations. Pass the original error (if any) so that +// ProtectedPathMutationError is only surfaced when no other error is +// propagating (mirrors Python's __exit__ exc_type handling). +func (g *ReadOnlyProjectGuard) Exit(origErr error) error { + currentFiles := walkProtected(g.protectedRoots) + currentSet := make(map[string]struct{}, len(currentFiles)) + for _, f := range currentFiles { + currentSet[f] = struct{}{} + } + + var violations []string + + // Newly-appeared files under protected roots are violations. + snapshotSet := make(map[string]struct{}, len(g.snapshot)) + for path := range g.snapshot { + snapshotSet[path] = struct{}{} + } + for path := range currentSet { + if _, seen := snapshotSet[path]; !seen { + violations = append(violations, fmt.Sprintf("created: %s", path)) + } + } + + // Snapshotted files that vanished or changed. + for path, prev := range g.snapshot { + cur := statFile(path) + if !prev.exists && !cur.exists { + continue // missing -> still missing: fine + } + if !prev.exists && cur.exists { + violations = append(violations, fmt.Sprintf("created: %s", path)) + } else if prev.exists && !cur.exists { + violations = append(violations, fmt.Sprintf("deleted: %s", path)) + } else if prev.mtimeNs != cur.mtimeNs || prev.size != cur.size { + violations = append(violations, fmt.Sprintf("modified: %s", path)) + } + } + + if len(violations) > 0 && origErr == nil { + sort.Strings(violations) + return &ProtectedPathMutationError{Violations: violations} + } + return nil +} diff --git a/internal/utils/guards/guards_test.go b/internal/utils/guards/guards_test.go new file mode 100644 index 0000000..fcfe467 --- /dev/null +++ b/internal/utils/guards/guards_test.go @@ -0,0 +1,126 @@ +package guards_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/githubnext/apm/internal/utils/guards" +) + +func TestNoMutation(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "file.txt") + if err := os.WriteFile(f, []byte("hello"), 0o644); err != nil { + t.Fatal(err) + } + + g := guards.NewReadOnlyProjectGuard(dir, []string{"file.txt"}) + if err := g.Enter(); err != nil { + t.Fatal(err) + } + // No mutation. + if err := g.Exit(nil); err != nil { + t.Fatalf("expected no error, got: %v", err) + } +} + +func TestModificationDetected(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "file.txt") + if err := os.WriteFile(f, []byte("hello"), 0o644); err != nil { + t.Fatal(err) + } + + g := guards.NewReadOnlyProjectGuard(dir, []string{"file.txt"}) + if err := g.Enter(); err != nil { + t.Fatal(err) + } + // Mutate the file. + if err := os.WriteFile(f, []byte("world"), 0o644); err != nil { + t.Fatal(err) + } + err := g.Exit(nil) + if err == nil { + t.Fatal("expected error for modified file") + } + var pe *guards.ProtectedPathMutationError + if ok := errorAs(err, &pe); !ok { + t.Fatalf("expected ProtectedPathMutationError, got %T", err) + } +} + +func TestDeletionDetected(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "file.txt") + if err := os.WriteFile(f, []byte("hello"), 0o644); err != nil { + t.Fatal(err) + } + + g := guards.NewReadOnlyProjectGuard(dir, []string{"file.txt"}) + if err := g.Enter(); err != nil { + t.Fatal(err) + } + os.Remove(f) + err := g.Exit(nil) + if err == nil { + t.Fatal("expected error for deleted file") + } +} + +func TestCreationDetected(t *testing.T) { + dir := t.TempDir() + + g := guards.NewReadOnlyProjectGuard(dir, []string{"."}) + if err := g.Enter(); err != nil { + t.Fatal(err) + } + // Create a new file. + f := filepath.Join(dir, "new.txt") + if err := os.WriteFile(f, []byte("new"), 0o644); err != nil { + t.Fatal(err) + } + err := g.Exit(nil) + if err == nil { + t.Fatal("expected error for created file") + } +} + +func TestMissingRootSilentlyIgnored(t *testing.T) { + dir := t.TempDir() + g := guards.NewReadOnlyProjectGuard(dir, []string{"nonexistent"}) + if err := g.Enter(); err != nil { + t.Fatal(err) + } + if err := g.Exit(nil); err != nil { + t.Fatalf("expected no error for missing root, got: %v", err) + } +} + +func TestOrigErrSuppressesGuardError(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "file.txt") + if err := os.WriteFile(f, []byte("hello"), 0o644); err != nil { + t.Fatal(err) + } + + g := guards.NewReadOnlyProjectGuard(dir, []string{"file.txt"}) + if err := g.Enter(); err != nil { + t.Fatal(err) + } + os.WriteFile(f, []byte("changed"), 0o644) + // When there is an original error, guard violation should be suppressed. + origErr := os.ErrNotExist + if err := g.Exit(origErr); err != nil { + t.Fatalf("expected guard to be suppressed, got: %v", err) + } +} + +// errorAs is a minimal errors.As helper to avoid importing errors package. +func errorAs(err error, target **guards.ProtectedPathMutationError) bool { + if pe, ok := err.(*guards.ProtectedPathMutationError); ok { + *target = pe + return true + } + return false +} diff --git a/internal/utils/helpers/helpers.go b/internal/utils/helpers/helpers.go new file mode 100644 index 0000000..37fc42e --- /dev/null +++ b/internal/utils/helpers/helpers.go @@ -0,0 +1,82 @@ +// Package helpers provides miscellaneous utility functions for APM. +package helpers + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" +) + +// IsToolAvailable reports whether a command-line tool can be found on PATH. +func IsToolAvailable(toolName string) bool { + _, err := exec.LookPath(toolName) + return err == nil +} + +// GetAvailablePackageManagers returns a map of package manager name -> name +// for every package manager binary found on PATH. +func GetAvailablePackageManagers() map[string]string { + candidates := []string{ + // Python + "uv", "pip", "pipx", + // JavaScript + "npm", "yarn", "pnpm", + // System + "brew", // macOS + "apt", // Debian/Ubuntu + "yum", // CentOS/RHEL + "dnf", // Fedora + "apk", // Alpine + "pacman", // Arch + } + out := make(map[string]string) + for _, name := range candidates { + if IsToolAvailable(name) { + out[name] = name + } + } + return out +} + +// DetectPlatform returns a normalised platform name: "macos", "linux", +// "windows", or "unknown". +func DetectPlatform() string { + switch runtime.GOOS { + case "darwin": + return "macos" + case "linux": + return "linux" + case "windows": + return "windows" + default: + return "unknown" + } +} + +// pluginJSONRelPaths is the ordered list of relative paths where plugin.json +// may live inside a plugin directory. +var pluginJSONRelPaths = []string{ + "plugin.json", + filepath.Join(".github", "plugin", "plugin.json"), + filepath.Join(".claude-plugin", "plugin.json"), + filepath.Join(".cursor-plugin", "plugin.json"), +} + +// FindPluginJSON searches for plugin.json in the well-known locations inside +// pluginPath and returns the first match. Returns an empty string when not found. +func FindPluginJSON(pluginPath string) string { + for _, rel := range pluginJSONRelPaths { + candidate := filepath.Join(pluginPath, rel) + if fileExists(candidate) { + return candidate + } + } + return "" +} + +// fileExists reports whether path refers to a regular file. +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && info.Mode().IsRegular() +} diff --git a/internal/utils/helpers/helpers_test.go b/internal/utils/helpers/helpers_test.go new file mode 100644 index 0000000..9b16304 --- /dev/null +++ b/internal/utils/helpers/helpers_test.go @@ -0,0 +1,70 @@ +package helpers_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/githubnext/apm/internal/utils/helpers" +) + +func TestIsToolAvailable(t *testing.T) { + // "sh" or "cat" should exist on any POSIX CI runner. + if !helpers.IsToolAvailable("sh") { + t.Error("expected 'sh' to be found on PATH") + } + if helpers.IsToolAvailable("definitely-not-a-real-binary-xyz") { + t.Error("expected nonexistent tool to return false") + } +} + +func TestDetectPlatform(t *testing.T) { + p := helpers.DetectPlatform() + valid := map[string]bool{"macos": true, "linux": true, "windows": true, "unknown": true} + if !valid[p] { + t.Errorf("unexpected platform %q", p) + } +} + +func TestGetAvailablePackageManagers(t *testing.T) { + // Just check it returns a map (may be empty in a minimal container). + m := helpers.GetAvailablePackageManagers() + if m == nil { + t.Error("expected non-nil map") + } +} + +func TestFindPluginJSON(t *testing.T) { + dir := t.TempDir() + + // No plugin.json yet. + if got := helpers.FindPluginJSON(dir); got != "" { + t.Errorf("expected empty, got %q", got) + } + + // Create the top-level plugin.json. + pj := filepath.Join(dir, "plugin.json") + if err := os.WriteFile(pj, []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + if got := helpers.FindPluginJSON(dir); got != pj { + t.Errorf("expected %q, got %q", pj, got) + } +} + +func TestFindPluginJSONSubdirs(t *testing.T) { + dir := t.TempDir() + + // Create under .github/plugin/ + sub := filepath.Join(dir, ".github", "plugin") + if err := os.MkdirAll(sub, 0o755); err != nil { + t.Fatal(err) + } + pj := filepath.Join(sub, "plugin.json") + if err := os.WriteFile(pj, []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + if got := helpers.FindPluginJSON(dir); got != pj { + t.Errorf("expected %q, got %q", pj, got) + } +} diff --git a/internal/utils/installtui/installtui.go b/internal/utils/installtui/installtui.go new file mode 100644 index 0000000..b8905d7 --- /dev/null +++ b/internal/utils/installtui/installtui.go @@ -0,0 +1,243 @@ +// Package installtui provides a shared Live-region TUI controller for the install pipeline. +// +// A single InstallTui instance is opened by apm install and is re-used +// across the resolve, download, integrate, and MCP-registry phases. +// Per-phase code calls StartPhase() once when the phase boundary is crossed, +// then TaskStarted() / TaskCompleted() / TaskFailed() for every dep / server / +// artifact in flight. +// +// When ShouldAnimate() is false (CI, dumb terminal, APM_PROGRESS=never, +// --quiet), every method on this struct is a cheap no-op. Callers do NOT +// need to gate their calls. +// +// This module uses a single ASCII spinner (| / - \) and never emits emoji +// or Unicode box-drawing, to stay safe under Windows cp1252. +package installtui + +import ( + "fmt" + "io" + "os" + "strings" + "sync" + "time" +) + +// DeferShowDuration is how long after Open() before the spinner is shown. +// Installs that finish under this threshold never paint a spinner. +const DeferShowDuration = 250 * time.Millisecond + +// refreshInterval is the spinner update interval (8 Hz). +const refreshInterval = 125 * time.Millisecond + +var spinnerFrames = []string{"|", "/", "-", "\\"} + +// InstallTui is the TUI controller. +type InstallTui struct { + out io.Writer + animate bool + quiet bool + + mu sync.Mutex + phase string + activeTasks map[string]bool + failedTasks map[string]string + completedCount int + failedCount int + + // spinner state + stopCh chan struct{} + stoppedCh chan struct{} + started bool +} + +// New creates a new InstallTui. quiet disables animation regardless of TTY. +func New(out io.Writer, quiet bool) *InstallTui { + if out == nil { + out = os.Stdout + } + animate := ShouldAnimate() && !quiet + return &InstallTui{ + out: out, + animate: animate, + quiet: quiet, + activeTasks: make(map[string]bool), + failedTasks: make(map[string]string), + } +} + +// ShouldAnimate returns true if the TUI should animate. +// Respects NO_COLOR, TERM=dumb, APM_PROGRESS env, and TTY detection. +func ShouldAnimate() bool { + prog := os.Getenv("APM_PROGRESS") + if prog == "never" || prog == "0" || prog == "false" { + return false + } + if prog == "always" || prog == "1" || prog == "true" { + return true + } + if os.Getenv("NO_COLOR") != "" || os.Getenv("TERM") == "dumb" { + return false + } + // Check if stdout is a TTY + fi, err := os.Stdout.Stat() + if err != nil { + return false + } + return (fi.Mode() & os.ModeCharDevice) != 0 +} + +// Open begins the TUI session (deferred by DeferShowDuration). +func (t *InstallTui) Open() { + if !t.animate { + return + } + t.mu.Lock() + if t.started { + t.mu.Unlock() + return + } + t.started = true + t.stopCh = make(chan struct{}) + t.stoppedCh = make(chan struct{}) + t.mu.Unlock() + + go t.spinLoop() +} + +// Close tears down the TUI session. +func (t *InstallTui) Close() { + t.mu.Lock() + if !t.started || t.stopCh == nil { + t.mu.Unlock() + return + } + stopCh := t.stopCh + t.mu.Unlock() + + close(stopCh) + <-t.stoppedCh + // Clear the spinner line + fmt.Fprint(t.out, "\r\033[K") +} + +// StartPhase signals a new install phase. +func (t *InstallTui) StartPhase(phase string) { + if !t.animate { + return + } + t.mu.Lock() + t.phase = phase + t.activeTasks = make(map[string]bool) + t.completedCount = 0 + t.failedCount = 0 + t.mu.Unlock() +} + +// TaskStarted records that a task has started. +func (t *InstallTui) TaskStarted(name string) { + if !t.animate { + return + } + t.mu.Lock() + t.activeTasks[name] = true + t.mu.Unlock() +} + +// TaskCompleted records that a task completed successfully. +func (t *InstallTui) TaskCompleted(name string) { + if !t.animate { + return + } + t.mu.Lock() + delete(t.activeTasks, name) + t.completedCount++ + t.mu.Unlock() +} + +// TaskFailed records that a task failed with the given reason. +func (t *InstallTui) TaskFailed(name, reason string) { + if !t.animate { + return + } + t.mu.Lock() + delete(t.activeTasks, name) + t.failedTasks[name] = reason + t.failedCount++ + t.mu.Unlock() +} + +func (t *InstallTui) spinLoop() { + defer close(t.stoppedCh) + // Defer showing the spinner + select { + case <-t.stopCh: + return + case <-time.After(DeferShowDuration): + } + frame := 0 + ticker := time.NewTicker(refreshInterval) + defer ticker.Stop() + for { + select { + case <-t.stopCh: + return + case <-ticker.C: + t.mu.Lock() + phase := t.phase + active := len(t.activeTasks) + completed := t.completedCount + failed := t.failedCount + // Pick first active task name + var firstName string + for k := range t.activeTasks { + firstName = k + break + } + t.mu.Unlock() + + spinner := spinnerFrames[frame%len(spinnerFrames)] + frame++ + line := buildSpinnerLine(spinner, phase, active, completed, failed, firstName) + fmt.Fprintf(t.out, "\r\033[K%s", line) + } + } +} + +func buildSpinnerLine(spinner, phase string, active, completed, failed int, firstName string) string { + var sb strings.Builder + sb.WriteString(spinner) + sb.WriteString(" ") + if phase != "" { + sb.WriteString("[") + sb.WriteString(phase) + sb.WriteString("] ") + } + if firstName != "" { + name := firstName + if len(name) > 40 { + name = name[:37] + "..." + } + sb.WriteString(name) + sb.WriteString(" ") + } + if active > 0 || completed > 0 || failed > 0 { + sb.WriteString(fmt.Sprintf("(%d active, %d done", active, completed)) + if failed > 0 { + sb.WriteString(fmt.Sprintf(", %d failed", failed)) + } + sb.WriteString(")") + } + return sb.String() +} + +// Enter implements a context-manager-style entry. Returns the tui itself. +func (t *InstallTui) Enter() *InstallTui { + t.Open() + return t +} + +// Exit implements a context-manager-style exit. +func (t *InstallTui) Exit(origErr error) { + t.Close() +} diff --git a/internal/utils/normalization/normalization.go b/internal/utils/normalization/normalization.go new file mode 100644 index 0000000..5f190e1 --- /dev/null +++ b/internal/utils/normalization/normalization.go @@ -0,0 +1,36 @@ +// Package normalization provides bytes-in / bytes-out content normalization helpers. +// Migrated from src/apm_cli/utils/normalization.py +package normalization + +import ( + "bytes" + "regexp" +) + +var ( + buildIDPattern = regexp.MustCompile(`(?i)\s*\n?`) + bom = []byte{0xef, 0xbb, 0xbf} +) + +// StripBuildID removes APM headers. +func StripBuildID(content []byte) []byte { + return buildIDPattern.ReplaceAll(content, nil) +} + +// NormalizeLineEndings converts CRLF to LF. +func NormalizeLineEndings(content []byte) []byte { + return bytes.ReplaceAll(content, []byte("\r\n"), []byte("\n")) +} + +// StripBOM drops a UTF-8 BOM at the start of the file. +func StripBOM(content []byte) []byte { + if bytes.HasPrefix(content, bom) { + return content[len(bom):] + } + return content +} + +// Normalize applies all drift-tolerant normalizations to a file's bytes. +func Normalize(content []byte) []byte { + return StripBuildID(NormalizeLineEndings(StripBOM(content))) +} diff --git a/internal/utils/paths/paths.go b/internal/utils/paths/paths.go new file mode 100644 index 0000000..28be475 --- /dev/null +++ b/internal/utils/paths/paths.go @@ -0,0 +1,31 @@ +// Package paths provides cross-platform path utilities for APM CLI. +// Migrated from src/apm_cli/utils/paths.py +package paths + +import ( + "path/filepath" + "strings" +) + +// PortableRelpath returns a forward-slash relative path, resolving both +// sides first. When path is not under base (or resolution fails), falls +// back to an absolute POSIX path. +func PortableRelpath(path, base string) string { + absPath, err := filepath.Abs(path) + if err != nil { + return toForwardSlash(path) + } + absBase, err := filepath.Abs(base) + if err != nil { + return toForwardSlash(absPath) + } + rel, err := filepath.Rel(absBase, absPath) + if err != nil { + return toForwardSlash(absPath) + } + return toForwardSlash(rel) +} + +func toForwardSlash(p string) string { + return strings.ReplaceAll(p, "\\", "/") +} diff --git a/internal/utils/pathsecurity/pathsecurity.go b/internal/utils/pathsecurity/pathsecurity.go new file mode 100644 index 0000000..3bbb6d7 --- /dev/null +++ b/internal/utils/pathsecurity/pathsecurity.go @@ -0,0 +1,108 @@ +// Package pathsecurity provides centralised path-security helpers for APM CLI. +// +// Every filesystem operation whose target is derived from user-controlled +// input must pass through one of these guards before touching the disk. +package pathsecurity + +import ( + "errors" + "net/url" + "os" + "path/filepath" + "strings" +) + +// PathTraversalError is returned when a computed path escapes its expected base directory. +type PathTraversalError struct { + msg string +} + +func (e *PathTraversalError) Error() string { return e.msg } + +func traversalErr(msg string) *PathTraversalError { + return &PathTraversalError{msg: msg} +} + +// ValidatePathSegments rejects path strings containing traversal sequences. +// +// Parameters: +// - pathStr: path-like string to validate (repo URL, virtual path, etc.) +// - context: human-readable label for error messages +// - rejectEmpty: if true, also reject empty segments +// - allowCurrentDir: if true, "." segments are accepted but ".." still rejected +func ValidatePathSegments(pathStr, context string, rejectEmpty, allowCurrentDir bool) error { + reject := map[string]bool{"..": true} + if !allowCurrentDir { + reject["."] = true + } + for _, segment := range strings.Split(strings.ReplaceAll(pathStr, `\`, "/"), "/") { + // Iteratively percent-decode each segment to catch multi-encoded traversal + decoded := segment + for i := 0; i < 8; i++ { + next, err := url.PathUnescape(decoded) + if err != nil || next == decoded { + break + } + decoded = next + } + if reject[segment] || reject[decoded] { + return traversalErr("Invalid " + context + " '" + pathStr + "': segment '" + segment + "' is a traversal sequence") + } + if rejectEmpty && segment == "" { + return traversalErr("Invalid " + context + " '" + pathStr + "': path segments must not be empty") + } + } + return nil +} + +// IsPathTraversalError reports whether err is a PathTraversalError. +func IsPathTraversalError(err error) bool { + var t *PathTraversalError + return errors.As(err, &t) +} + +// EnsurePathWithin resolves path and asserts it lives inside baseDir. +// +// Returns the resolved path on success. Raises PathTraversalError if the +// resolved path escapes baseDir. +func EnsurePathWithin(path, baseDir string) (string, error) { + resolved, err := filepath.EvalSymlinks(path) + if err != nil { + // Fall back to Abs if EvalSymlinks fails (path may not exist yet) + resolved, err = filepath.Abs(path) + if err != nil { + return "", traversalErr("Cannot resolve path '" + path + "': " + err.Error()) + } + } + resolvedBase, err := filepath.EvalSymlinks(baseDir) + if err != nil { + resolvedBase, err = filepath.Abs(baseDir) + if err != nil { + return "", traversalErr("Cannot resolve base dir '" + baseDir + "': " + err.Error()) + } + } + // Strip Windows extended-length prefix + resolved = stripExtendedPrefix(resolved) + resolvedBase = stripExtendedPrefix(resolvedBase) + + rel, err := filepath.Rel(resolvedBase, resolved) + if err != nil || strings.HasPrefix(rel, "..") { + return "", traversalErr("Path '" + path + "' resolves to '" + resolved + "' which is outside the allowed base directory '" + resolvedBase + "'") + } + return resolved, nil +} + +func stripExtendedPrefix(p string) string { + if strings.HasPrefix(p, `\\?\`) { + return p[4:] + } + return p +} + +// SafeRmtree removes path only if it resolves within baseDir. +func SafeRmtree(path, baseDir string) error { + if _, err := EnsurePathWithin(path, baseDir); err != nil { + return err + } + return os.RemoveAll(path) +} diff --git a/internal/utils/pathsecurity/pathsecurity_test.go b/internal/utils/pathsecurity/pathsecurity_test.go new file mode 100644 index 0000000..4681530 --- /dev/null +++ b/internal/utils/pathsecurity/pathsecurity_test.go @@ -0,0 +1,48 @@ +package pathsecurity_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/githubnext/apm/internal/utils/pathsecurity" +) + +func TestValidatePathSegments(t *testing.T) { + tests := []struct { + path string + wantErr bool + }{ + {"foo/bar/baz", false}, + {"../etc/passwd", true}, + {"foo/../etc", true}, + {"./relative", true}, + {"foo/bar", false}, + } + for _, tt := range tests { + err := pathsecurity.ValidatePathSegments(tt.path, "test", false, false) + if (err != nil) != tt.wantErr { + t.Errorf("ValidatePathSegments(%q) err=%v, wantErr=%v", tt.path, err, tt.wantErr) + } + } +} + +func TestEnsurePathWithin(t *testing.T) { + base, err := os.MkdirTemp("", "pathsec-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(base) + + safe := filepath.Join(base, "subdir", "file.txt") + os.MkdirAll(filepath.Dir(safe), 0o755) + os.WriteFile(safe, []byte("x"), 0o644) + + if _, err := pathsecurity.EnsurePathWithin(safe, base); err != nil { + t.Errorf("expected safe path to pass, got err: %v", err) + } + + if _, err := pathsecurity.EnsurePathWithin("/etc/passwd", base); err == nil { + t.Error("expected /etc/passwd to fail containment check") + } +} diff --git a/internal/utils/reflink/reflink.go b/internal/utils/reflink/reflink.go new file mode 100644 index 0000000..f80b347 --- /dev/null +++ b/internal/utils/reflink/reflink.go @@ -0,0 +1,97 @@ +// Package reflink provides copy-on-write file cloning (reflinks) for fast +// large-tree materialisation. +// +// Modern filesystems (APFS on macOS, btrfs and XFS on Linux) support +// copy-on-write clones. This package attempts reflinks where possible and +// falls back to regular file copies transparently. +// +// API: +// - CloneFile: attempt to reflink one file; return true on success +// - ReflinkSupported: best-effort runtime probe +package reflink + +import ( + "io" + "os" + "path/filepath" + "sync" +) + +// NoReflinkEnv disables reflinks when set to "1". +const NoReflinkEnv = "APM_NO_REFLINK" + +// deviceCapability caches per-device reflink support (st_dev -> bool). +var ( + deviceCapability = map[uint64]bool{} + capMu sync.Mutex +) + +// CloneFile attempts to create a reflink clone of src at dst. +// Falls back to a regular copy if reflinks are not supported. +// Returns true if a reflink was used, false if a regular copy was used. +func CloneFile(src, dst string) (bool, error) { + if os.Getenv(NoReflinkEnv) == "1" { + return false, regularCopy(src, dst) + } + + // Try platform-specific reflink + ok, err := platformClone(src, dst) + if err != nil { + return false, err + } + if ok { + return true, nil + } + // Fall back to copy + return false, regularCopy(src, dst) +} + +// ReflinkSupported returns true if reflinks are likely supported on the filesystem +// containing path. +func ReflinkSupported(path string) bool { + if os.Getenv(NoReflinkEnv) == "1" { + return false + } + dev, err := deviceID(path) + if err != nil { + return false + } + capMu.Lock() + supported, probed := deviceCapability[dev] + capMu.Unlock() + if probed { + return supported + } + return platformSupported(path) +} + +func regularCopy(src, dst string) error { + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + info, err := in.Stat() + if err != nil { + return err + } + + out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode()) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, in) + return err +} + +// setCachedCapability records whether the device supports reflinks. +func setCachedCapability(dev uint64, supported bool) { + capMu.Lock() + deviceCapability[dev] = supported + capMu.Unlock() +} diff --git a/internal/utils/reflink/reflink_linux.go b/internal/utils/reflink/reflink_linux.go new file mode 100644 index 0000000..0561688 --- /dev/null +++ b/internal/utils/reflink/reflink_linux.go @@ -0,0 +1,104 @@ +//go:build linux + +package reflink + +import ( + "os" + "syscall" + "unsafe" +) + +// FICLONE ioctl number on Linux: _IOW(0x94, 9, int) = 0x40049409 +const ficlone = 0x40049409 + +func platformClone(src, dst string) (bool, error) { + if err := os.MkdirAll(getDir(dst), 0o755); err != nil { + return false, err + } + + in, err := os.Open(src) + if err != nil { + return false, err + } + defer in.Close() + + info, err := in.Stat() + if err != nil { + return false, err + } + + out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode()) + if err != nil { + return false, err + } + defer func() { + out.Close() + if err != nil { + os.Remove(dst) + } + }() + + // Check device capability cache + inStat, statErr := in.Stat() + if statErr == nil { + if sysInfo, ok := inStat.Sys().(*syscall.Stat_t); ok { + dev := sysInfo.Dev + capMu.Lock() + supported, probed := deviceCapability[dev] + capMu.Unlock() + if probed && !supported { + out.Close() + return false, regularCopy(src, dst) + } + } + } + + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, out.Fd(), ficlone, in.Fd()) + if errno == 0 { + // Record success + if statErr == nil { + if sysInfo, ok := inStat.Sys().(*syscall.Stat_t); ok { + setCachedCapability(sysInfo.Dev, true) + } + } + return true, nil + } + // errno indicates not supported -- cache and fall through + if statErr == nil { + if sysInfo, ok := inStat.Sys().(*syscall.Stat_t); ok { + setCachedCapability(sysInfo.Dev, false) + } + } + out.Close() + return false, regularCopy(src, dst) +} + +func platformSupported(path string) bool { + // probe by attempting a clone of a temp file + return false // conservative: return false without actually probing +} + +func deviceID(path string) (uint64, error) { + info, err := os.Stat(path) + if err != nil { + return 0, err + } + if sysInfo, ok := info.Sys().(*syscall.Stat_t); ok { + return sysInfo.Dev, nil + } + return 0, nil +} + +func getDir(path string) string { + dir := path + for len(dir) > 0 && dir[len(dir)-1] != '/' && dir[len(dir)-1] != '\\' { + dir = dir[:len(dir)-1] + } + if dir == "" { + return "." + } + return dir +} + +// ensure unused import is not flagged +var _ = unsafe.Pointer(nil) diff --git a/internal/utils/reflink/reflink_other.go b/internal/utils/reflink/reflink_other.go new file mode 100644 index 0000000..d8386f6 --- /dev/null +++ b/internal/utils/reflink/reflink_other.go @@ -0,0 +1,22 @@ +//go:build !linux && !darwin + +package reflink + +import "os" + +func platformClone(src, dst string) (bool, error) { + return false, nil // no reflink support on this platform +} + +func platformSupported(path string) bool { + return false +} + +func deviceID(path string) (uint64, error) { + info, err := os.Stat(path) + if err != nil { + return 0, err + } + _ = info + return 0, nil +} diff --git a/internal/utils/sha/sha.go b/internal/utils/sha/sha.go new file mode 100644 index 0000000..c3ee168 --- /dev/null +++ b/internal/utils/sha/sha.go @@ -0,0 +1,38 @@ +// Package sha provides short-form SHA helpers for user-facing output. +// Migrated from src/apm_cli/utils/short_sha.py +package sha + +import "strings" + +var sentinels = map[string]struct{}{ + "cached": {}, + "unknown": {}, +} + +// FormatShortSHA returns an 8-char short SHA or "" for invalid inputs. +// Non-string inputs (empty string) and sentinel values collapse to "". +// Strings shorter than 8 chars or containing non-hex characters return "". +func FormatShortSHA(value string) string { + candidate := strings.TrimSpace(value) + if candidate == "" { + return "" + } + if _, isSentinel := sentinels[strings.ToLower(candidate)]; isSentinel { + return "" + } + if len(candidate) < 8 { + return "" + } + for _, ch := range candidate { + if !isHex(ch) { + return "" + } + } + return candidate[:8] +} + +func isHex(r rune) bool { + return (r >= '0' && r <= '9') || + (r >= 'a' && r <= 'f') || + (r >= 'A' && r <= 'F') +} diff --git a/internal/utils/sha/sha_test.go b/internal/utils/sha/sha_test.go new file mode 100644 index 0000000..1e2acc5 --- /dev/null +++ b/internal/utils/sha/sha_test.go @@ -0,0 +1,31 @@ +// Package sha_test tests the SHA short-form helper. +package sha_test + +import ( + "testing" + + "github.com/githubnext/apm/internal/utils/sha" +) + +func TestFormatShortSHA(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"", ""}, + {"cached", ""}, + {"unknown", ""}, + {"CACHED", ""}, + {"abc123", ""}, // too short + {"abc12345", "abc12345"}, // exactly 8 hex chars + {"abc123456789abcd", "abc12345"}, + {"xyz12345", ""}, // non-hex char + {" abc12345 ", "abc12345"}, // trims whitespace + } + for _, tt := range tests { + got := sha.FormatShortSHA(tt.input) + if got != tt.want { + t.Errorf("FormatShortSHA(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} diff --git a/internal/utils/subprocenv/subprocenv.go b/internal/utils/subprocenv/subprocenv.go new file mode 100644 index 0000000..015935d --- /dev/null +++ b/internal/utils/subprocenv/subprocenv.go @@ -0,0 +1,104 @@ +// Package subprocenv provides environment sanitisation for spawning external +// processes from a PyInstaller-frozen binary. +// +// When APM ships as a PyInstaller --onedir binary the bootloader prepends +// the bundle's _internal directory to LD_LIBRARY_PATH (Linux) and the +// DYLD_* variables (macOS) so that the main Python process can find its own +// shared libraries. Child processes inherit that environment by default, +// which causes system binaries (git, curl, the install script) to resolve +// their dependencies against the bundled libraries. This package centralises +// the restoration logic that mirrors the Python subprocess_env module. +package subprocenv + +import ( + "os" + "runtime" + "strings" +) + +// pyinstallerManagedVars are the library-path variables that PyInstaller's +// bootloader rewrites at launch. Each has a sibling _ORIG holding the +// pre-launch value that must be restored before handing the environment to a +// child process. +var pyinstallerManagedVars = []string{ + "LD_LIBRARY_PATH", // Linux and most Unixes + "DYLD_LIBRARY_PATH", // macOS dynamic library search path + "DYLD_FRAMEWORK_PATH", // macOS framework search path +} + +// isFrozen returns true when the process was started by PyInstaller. This is +// detected by checking for the _MEIPASS environment variable that PyInstaller +// always sets in a frozen binary. +func isFrozen() bool { + _, ok := os.LookupEnv("_MEIPASS") + return ok +} + +// ExternalProcessEnv returns an environment map safe for spawning external +// system binaries. +// +// When not running as a PyInstaller-frozen binary the current os.Environ() is +// returned as a fresh map with no modifications. +// +// When frozen, every library-path variable in pyinstallerManagedVars is +// restored from its _ORIG sibling. If no _ORIG sibling exists the +// variable is removed entirely so the child does not inherit the bundle's +// _internal path. The _ORIG keys themselves are stripped. +// +// If base is non-nil it is used as the source mapping instead of os.Environ(). +func ExternalProcessEnv(base map[string]string) map[string]string { + env := envToMap(base) + + if !isFrozen() { + return env + } + + for _, key := range pyinstallerManagedVars { + origKey := key + "_ORIG" + if origVal, ok := env[origKey]; ok { + env[key] = origVal + delete(env, origKey) + } else { + delete(env, key) + } + } + return env +} + +// envToMap converts a []string slice (KEY=VALUE pairs) or an existing map into +// a fresh map[string]string copy. When base is nil os.Environ() is used. +func envToMap(base map[string]string) map[string]string { + if base != nil { + out := make(map[string]string, len(base)) + for k, v := range base { + out[k] = v + } + return out + } + pairs := os.Environ() + out := make(map[string]string, len(pairs)) + for _, pair := range pairs { + idx := strings.IndexByte(pair, '=') + if idx < 0 { + out[pair] = "" + continue + } + out[pair[:idx]] = pair[idx+1:] + } + return out +} + +// MapToSlice converts a map[string]string into a []string of KEY=VALUE pairs +// suitable for exec.Cmd.Env. +func MapToSlice(env map[string]string) []string { + out := make([]string, 0, len(env)) + for k, v := range env { + out = append(out, k+"="+v) + } + return out +} + +// IsWindows reports whether the current OS is Windows. +func IsWindows() bool { + return runtime.GOOS == "windows" +} diff --git a/internal/utils/subprocenv/subprocenv_test.go b/internal/utils/subprocenv/subprocenv_test.go new file mode 100644 index 0000000..4a324d6 --- /dev/null +++ b/internal/utils/subprocenv/subprocenv_test.go @@ -0,0 +1,44 @@ +package subprocenv_test + +import ( + "testing" + + "github.com/githubnext/apm/internal/utils/subprocenv" +) + +func TestExternalProcessEnvNoFreeze(t *testing.T) { + // Without _MEIPASS set, ExternalProcessEnv returns a clean copy of the base. + base := map[string]string{ + "LD_LIBRARY_PATH": "/bundled/lib", + "LD_LIBRARY_PATH_ORIG": "/usr/lib", + "HOME": "/home/user", + } + env := subprocenv.ExternalProcessEnv(base) + // When not frozen the map is returned as-is (no restoration). + if env["LD_LIBRARY_PATH"] != "/bundled/lib" { + t.Errorf("expected /bundled/lib, got %s", env["LD_LIBRARY_PATH"]) + } +} + +func TestMapToSlice(t *testing.T) { + env := map[string]string{"FOO": "bar", "BAZ": "qux"} + slice := subprocenv.MapToSlice(env) + if len(slice) != 2 { + t.Errorf("expected 2 entries, got %d", len(slice)) + } + seen := map[string]bool{} + for _, s := range slice { + seen[s] = true + } + if !seen["FOO=bar"] || !seen["BAZ=qux"] { + t.Errorf("missing expected entries: %v", slice) + } +} + +func TestExternalProcessEnvNilBase(t *testing.T) { + // With nil base we get the real process env -- just verify no panic. + env := subprocenv.ExternalProcessEnv(nil) + if env == nil { + t.Error("expected non-nil env") + } +} diff --git a/internal/utils/versionchecker/versionchecker.go b/internal/utils/versionchecker/versionchecker.go new file mode 100644 index 0000000..506b56d --- /dev/null +++ b/internal/utils/versionchecker/versionchecker.go @@ -0,0 +1,165 @@ +// Package versionchecker provides version checking and update notification utilities. +package versionchecker + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "runtime" + "strconv" + "time" +) + +var versionRe = regexp.MustCompile(`^(\d+)\.(\d+)\.(\d+)(a\d+|b\d+|rc\d+)?$`) + +// VersionComponents holds parsed version parts. +type VersionComponents struct { + Major int + Minor int + Patch int + Prerelease string +} + +// GetLatestVersionFromGitHub fetches the latest release version from GitHub API. +// Returns empty string if unable to fetch. +func GetLatestVersionFromGitHub(repo string, timeoutSecs int) string { + if repo == "" { + repo = "microsoft/apm" + } + if timeoutSecs <= 0 { + timeoutSecs = 2 + } + url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo) + client := &http.Client{Timeout: time.Duration(timeoutSecs) * time.Second} + resp, err := client.Get(url) + if err != nil { + return "" + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return "" + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return "" + } + var data struct { + TagName string `json:"tag_name"` + } + if err := json.Unmarshal(body, &data); err != nil { + return "" + } + tag := data.TagName + if len(tag) > 0 && tag[0] == 'v' { + tag = tag[1:] + } + if versionRe.MatchString(tag) { + return tag + } + return "" +} + +// ParseVersion parses a semantic version string into components. +// Returns nil if the string is not a valid version. +func ParseVersion(versionStr string) *VersionComponents { + m := versionRe.FindStringSubmatch(versionStr) + if m == nil { + return nil + } + major, _ := strconv.Atoi(m[1]) + minor, _ := strconv.Atoi(m[2]) + patch, _ := strconv.Atoi(m[3]) + return &VersionComponents{Major: major, Minor: minor, Patch: patch, Prerelease: m[4]} +} + +// IsNewerVersion returns true if latest is newer than current. +func IsNewerVersion(current, latest string) bool { + c := ParseVersion(current) + l := ParseVersion(latest) + if c == nil || l == nil { + return false + } + if l.Major != c.Major { + return l.Major > c.Major + } + if l.Minor != c.Minor { + return l.Minor > c.Minor + } + if l.Patch != c.Patch { + return l.Patch > c.Patch + } + // Same major.minor.patch -- compare prerelease + // Stable (no prerelease) is newer than prerelease + if l.Prerelease == "" && c.Prerelease != "" { + return true + } + if l.Prerelease != "" && c.Prerelease == "" { + return false + } + return l.Prerelease > c.Prerelease +} + +// GetUpdateCachePath returns the path to the version update cache file. +func GetUpdateCachePath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + var cacheDir string + if runtime.GOOS == "windows" { + cacheDir = filepath.Join(home, "AppData", "Local", "apm", "cache") + } else { + cacheDir = filepath.Join(home, ".cache", "apm") + } + if err := os.MkdirAll(cacheDir, 0o755); err != nil { + return "", err + } + return filepath.Join(cacheDir, "last_version_check"), nil +} + +// ShouldCheckForUpdates returns true if a version check is due (at most once per day). +func ShouldCheckForUpdates() bool { + path, err := GetUpdateCachePath() + if err != nil { + return true + } + info, err := os.Stat(path) + if err != nil { + return true // file doesn't exist + } + return time.Since(info.ModTime()) > 24*time.Hour +} + +// SaveVersionCheckTimestamp saves the timestamp of the last version check. +func SaveVersionCheckTimestamp() { + path, err := GetUpdateCachePath() + if err != nil { + return + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) + if err != nil { + return + } + f.Close() +} + +// CheckForUpdates checks if a newer version is available. Returns the latest +// version string if an update is available, empty string otherwise. +func CheckForUpdates(currentVersion string) string { + if !ShouldCheckForUpdates() { + return "" + } + latest := GetLatestVersionFromGitHub("microsoft/apm", 2) + SaveVersionCheckTimestamp() + if latest == "" { + return "" + } + if IsNewerVersion(currentVersion, latest) { + return latest + } + return "" +} diff --git a/internal/utils/yamlio/yamlio.go b/internal/utils/yamlio/yamlio.go new file mode 100644 index 0000000..fb9758e --- /dev/null +++ b/internal/utils/yamlio/yamlio.go @@ -0,0 +1,71 @@ +// Package yamlio provides cross-platform YAML I/O with guaranteed UTF-8 encoding. +// Mirrors src/apm_cli/utils/yaml_io.py. +// +// NOTE: Full YAML parsing requires an external library (gopkg.in/yaml.v3). This +// package provides the API surface and a minimal implementation that handles the +// common cases APM uses (string/int/bool values, no anchors/aliases). Production +// callers that need full YAML support should build with gopkg.in/yaml.v3 and swap +// the internal parseYAML / marshalYAML implementations. +package yamlio + +import ( + "bufio" + "fmt" + "os" + "strings" +) + +// LoadYAML reads a YAML file and returns the parsed data as a flat map. +// Returns nil for empty files. Returns an error on failure. +func LoadYAML(path string) (map[string]any, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + if len(strings.TrimSpace(string(data))) == 0 { + return nil, nil + } + return parseSimpleYAML(string(data)) +} + +// DumpYAML writes data to a YAML file with UTF-8 encoding. +func DumpYAML(data any, path string) error { + out, err := YAMLToStr(data) + if err != nil { + return err + } + return os.WriteFile(path, []byte(out), 0o644) +} + +// YAMLToStr serializes a map[string]any to a minimal YAML string. +func YAMLToStr(data any) (string, error) { + m, ok := data.(map[string]any) + if !ok { + return fmt.Sprintf("%v\n", data), nil + } + var sb strings.Builder + for k, v := range m { + sb.WriteString(fmt.Sprintf("%s: %v\n", k, v)) + } + return sb.String(), nil +} + +// parseSimpleYAML handles flat "key: value" YAML (no nesting, anchors, or sequences). +func parseSimpleYAML(content string) (map[string]any, error) { + result := map[string]any{} + scanner := bufio.NewScanner(strings.NewReader(content)) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(strings.TrimSpace(line), "#") || strings.TrimSpace(line) == "" { + continue + } + idx := strings.Index(line, ":") + if idx < 0 { + continue + } + key := strings.TrimSpace(line[:idx]) + val := strings.TrimSpace(line[idx+1:]) + result[key] = val + } + return result, scanner.Err() +} diff --git a/internal/utils/yamlio/yamlio_test.go b/internal/utils/yamlio/yamlio_test.go new file mode 100644 index 0000000..2075e64 --- /dev/null +++ b/internal/utils/yamlio/yamlio_test.go @@ -0,0 +1,50 @@ +package yamlio_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/githubnext/apm/internal/utils/yamlio" +) + +func TestRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.yaml") + + data := map[string]any{ + "key": "value", + "num": 42, + } + + if err := yamlio.DumpYAML(data, path); err != nil { + t.Fatalf("DumpYAML: %v", err) + } + + loaded, err := yamlio.LoadYAML(path) + if err != nil { + t.Fatalf("LoadYAML: %v", err) + } + + if loaded["key"] != "value" { + t.Errorf("key: got %v, want value", loaded["key"]) + } +} + +func TestLoadMissing(t *testing.T) { + _, err := yamlio.LoadYAML("/nonexistent/file.yaml") + if !os.IsNotExist(err) { + t.Errorf("expected not-exist error, got %v", err) + } +} + +func TestYAMLToStr(t *testing.T) { + data := map[string]any{"a": 1} + s, err := yamlio.YAMLToStr(data) + if err != nil { + t.Fatalf("YAMLToStr: %v", err) + } + if s == "" { + t.Error("expected non-empty YAML string") + } +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..6a62279 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,67 @@ +// Package version provides version resolution for APM CLI. +// Migrated from src/apm_cli/version.py +package version + +import ( + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strings" +) + +// BuildVersion is optionally injected at build time via -ldflags. +var BuildVersion string + +// BuildSHA is optionally injected at build time via -ldflags. +var BuildSHA string + +var versionRe = regexp.MustCompile(`version\s*=\s*["']([^"']+)["']`) +var pep440Re = regexp.MustCompile(`^\d+\.\d+\.\d+(a\d+|b\d+|rc\d+)?$`) + +// GetVersion returns the current version string. +// Priority: build-time constant > pyproject.toml parse > "unknown". +func GetVersion() string { + if BuildVersion != "" { + return BuildVersion + } + // Locate pyproject.toml relative to this source file (dev mode). + _, file, _, ok := runtime.Caller(0) + if ok { + repoRoot := filepath.Join(filepath.Dir(file), "..", "..", "..") + pyproject := filepath.Join(repoRoot, "pyproject.toml") + if v := versionFromPyproject(pyproject); v != "" { + return v + } + } + return "unknown" +} + +func versionFromPyproject(path string) string { + data, err := os.ReadFile(filepath.Clean(path)) + if err != nil { + return "" + } + m := versionRe.FindStringSubmatch(string(data)) + if m == nil { + return "" + } + v := m[1] + if !pep440Re.MatchString(v) { + return "" + } + return v +} + +// GetBuildSHA returns the short git commit SHA. +func GetBuildSHA() string { + if BuildSHA != "" { + return BuildSHA + } + out, err := exec.Command("git", "rev-parse", "--short", "HEAD").Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +}