Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions cli/azd/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@

### Features Added

- Add `ConfigHelper` for typed, ergonomic access to azd user and environment configuration through gRPC services, with validation support, shallow/deep merge, and structured error types (`ConfigError`).
- Add `Pager[T]` generic pagination helper with SSRF-safe nextLink validation, `Collect` with `MaxPages`/`MaxItems` bounds, and `Truncated()` detection for callers.
- Add `ResilientClient` hardening: exponential backoff with jitter, upfront body seekability validation, and `Retry-After` header cap at 120 s.
- Add `SSRFGuard` standalone SSRF protection with metadata endpoint blocking, private network blocking, HTTPS enforcement, DNS fail-closed, IPv6 embedding extraction, and allowlist bypass.
- Add atomic file operations (`WriteFileAtomic`, `CopyFileAtomic`, `BackupFile`, `EnsureDir`) with crash-safe write-temp-rename pattern.
- Add runtime process utilities for cross-platform process management, tool discovery, and shell execution helpers.

### Breaking Changes

### Bugs Fixed
Expand Down
219 changes: 219 additions & 0 deletions cli/azd/pkg/azdext/atomicfile.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package azdext

import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"

"github.com/azure/azure-dev/cli/azd/pkg/osutil"
)

// ---------------------------------------------------------------------------
// P3-2: Atomic file operations
// ---------------------------------------------------------------------------

// WriteFileAtomic writes data to the named file atomically. It writes to a
// temporary file in the same directory as path and renames it into place. This
// ensures that readers never see a partially-written file and that the
// operation is crash-safe on filesystems that support atomic rename (ext4,
// APFS, NTFS).
//
// Platform behavior:
// - Unix: os.Rename is atomic within the same filesystem.
// - Windows: os.Rename replaces the target if it exists (Go 1.16+). On
// older Go runtimes or cross-device moves, the operation may fail.
// WriteFileAtomic always places the temp file in the same directory to
// avoid cross-device issues.
//
// The file is created with the specified permissions. If the target already
// exists its permissions are preserved unless perm is explicitly non-zero.
//
// Returns an error if the directory does not exist, the temp file cannot be
// created, data cannot be written, or the rename fails.
func WriteFileAtomic(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)

// Validate that the target directory exists.
if _, err := os.Stat(dir); err != nil {
return fmt.Errorf("azdext.WriteFileAtomic: target directory: %w", err)
}

// If perm is zero and the target exists, preserve existing permissions.
if perm == 0 {
if fi, err := os.Stat(path); err == nil {
perm = fi.Mode().Perm()
} else {
perm = 0o644
}
}

// Create temp file in the same directory (same filesystem = atomic rename).
tmp, err := os.CreateTemp(dir, ".azdext-atomic-*")
if err != nil {
return fmt.Errorf("azdext.WriteFileAtomic: create temp: %w", err)
}
tmpPath := tmp.Name()

// Ensure cleanup on any failure path.
success := false
defer func() {
if !success {
_ = tmp.Close()
_ = os.Remove(tmpPath)
}
}()

// Write data and sync to disk.
if _, err := tmp.Write(data); err != nil {
return fmt.Errorf("azdext.WriteFileAtomic: write: %w", err)
}
if err := tmp.Sync(); err != nil {
return fmt.Errorf("azdext.WriteFileAtomic: sync: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("azdext.WriteFileAtomic: close: %w", err)
}

// Set permissions on temp file before rename.
if err := os.Chmod(tmpPath, perm); err != nil {
return fmt.Errorf("azdext.WriteFileAtomic: chmod: %w", err)
}

// Atomic rename into place.
if err := osutil.Rename(context.Background(), tmpPath, path); err != nil {
return fmt.Errorf("azdext.WriteFileAtomic: rename: %w", err)
}

success = true
return nil
}

// CopyFileAtomic copies src to dst atomically using the write-temp-rename
// pattern. The destination file is never in a partially-written state.
// The copy is streamed through a fixed-size buffer (no unbounded memory
// allocation regardless of source file size).
//
// Platform behavior: see [WriteFileAtomic].
//
// If perm is zero, the source file's permissions are used.
func CopyFileAtomic(src, dst string, perm os.FileMode) error {
srcFile, err := os.Open(src)
if err != nil {
return fmt.Errorf("azdext.CopyFileAtomic: open source: %w", err)
}
defer srcFile.Close()

// Determine permissions.
if perm == 0 {
if fi, err := srcFile.Stat(); err == nil {
perm = fi.Mode().Perm()
} else {
perm = 0o644
}
}

dir := filepath.Dir(dst)

// Validate that the target directory exists.
if _, err := os.Stat(dir); err != nil {
return fmt.Errorf("azdext.CopyFileAtomic: target directory: %w", err)
}

// Create temp file in the same directory (same filesystem = atomic rename).
tmp, err := os.CreateTemp(dir, ".azdext-atomic-*")
if err != nil {
return fmt.Errorf("azdext.CopyFileAtomic: create temp: %w", err)
}
tmpPath := tmp.Name()

// Ensure cleanup on any failure path.
success := false
defer func() {
if !success {
_ = tmp.Close()
_ = os.Remove(tmpPath)
}
}()

// Stream copy with fixed-size buffer — no unbounded memory allocation.
if _, err := io.Copy(tmp, srcFile); err != nil {
return fmt.Errorf("azdext.CopyFileAtomic: copy: %w", err)
}

if err := tmp.Sync(); err != nil {
return fmt.Errorf("azdext.CopyFileAtomic: sync: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("azdext.CopyFileAtomic: close: %w", err)
}

// Set permissions on temp file before rename.
if err := os.Chmod(tmpPath, perm); err != nil {
return fmt.Errorf("azdext.CopyFileAtomic: chmod: %w", err)
}

// Atomic rename into place.
if err := osutil.Rename(context.Background(), tmpPath, dst); err != nil {
return fmt.Errorf("azdext.CopyFileAtomic: rename: %w", err)
}

success = true
return nil
}

// BackupFile creates a backup copy of path at path+suffix using atomic copy.
// If the source file does not exist, it returns nil (no backup needed).
//
// The default suffix is ".bak" if suffix is empty.
//
// Returns the backup path on success, or an error if the copy fails.
func BackupFile(path, suffix string) (string, error) {
if suffix == "" {
suffix = ".bak"
}

if _, err := os.Stat(path); os.IsNotExist(err) {
return "", nil // Nothing to back up.
}

backupPath := path + suffix
if err := CopyFileAtomic(path, backupPath, 0); err != nil {
return "", fmt.Errorf("azdext.BackupFile: %w", err)
}

return backupPath, nil
}

// EnsureDir creates directory dir and any necessary parents with the given
// permissions. If the directory already exists, EnsureDir is a no-op and
// returns nil.
//
// This is a convenience wrapper around [os.MkdirAll] with an explicit error
// prefix for diagnostics.
//
// Security: EnsureDir cleans the path via [filepath.Clean] and rejects paths
// containing parent-directory traversal ("..") to prevent creating directories
// outside the caller's intended scope. For untrusted input, callers should
// additionally use [MCPSecurityPolicy.CheckPath] for base-directory validation.
func EnsureDir(dir string, perm os.FileMode) error {
if perm == 0 {
perm = 0o755
}

// Reject paths containing parent traversal sequences.
cleaned := filepath.Clean(dir)
if strings.Contains(cleaned, "..") {
return fmt.Errorf("azdext.EnsureDir: path traversal detected in %q", dir)
}

if err := os.MkdirAll(cleaned, perm); err != nil {
return fmt.Errorf("azdext.EnsureDir: %w", err)
}
return nil
}
Loading