github.com/fastly/cli@v1.7.2-0.20240304164155-9d0f1d77c3bf/pkg/commands/compute/language_rust.go (about)

     1  package compute
     2  
     3  import (
     4  	"bytes"
     5  	"encoding/json"
     6  	"fmt"
     7  	"io"
     8  	"os"
     9  	"os/exec"
    10  	"path/filepath"
    11  	"regexp"
    12  	"strings"
    13  
    14  	"github.com/Masterminds/semver/v3"
    15  	toml "github.com/pelletier/go-toml"
    16  
    17  	"github.com/fastly/cli/pkg/config"
    18  	fsterr "github.com/fastly/cli/pkg/errors"
    19  	"github.com/fastly/cli/pkg/filesystem"
    20  	"github.com/fastly/cli/pkg/text"
    21  )
    22  
    23  // RustDefaultBuildCommand is a build command compiled into the CLI binary so it
    24  // can be used as a fallback for customer's who have an existing Compute project and
    25  // are simply upgrading their CLI version and might not be familiar with the
    26  // changes in the 4.0.0 release with regards to how build logic has moved to the
    27  // fastly.toml manifest.
    28  //
    29  // NOTE: In the 5.x CLI releases we persisted the default to the fastly.toml
    30  // We no longer do that. In 6.x we use the default and just inform the user.
    31  // This makes the experience less confusing as users didn't expect file changes.
    32  const RustDefaultBuildCommand = "cargo build --bin %s --release --target wasm32-wasi --color always"
    33  
    34  // RustManifest is the manifest file for defining project configuration.
    35  const RustManifest = "Cargo.toml"
    36  
    37  // RustDefaultPackageName is the expected binary create/package name to be built.
    38  const RustDefaultPackageName = "fastly-compute-project"
    39  
    40  // RustSourceDirectory represents the source code directory.
    41  const RustSourceDirectory = "src"
    42  
    43  // NewRust constructs a new Rust toolchain.
    44  func NewRust(
    45  	c *BuildCommand,
    46  	in io.Reader,
    47  	manifestFilename string,
    48  	out io.Writer,
    49  	spinner text.Spinner,
    50  ) *Rust {
    51  	return &Rust{
    52  		Shell: Shell{},
    53  
    54  		autoYes:               c.Globals.Flags.AutoYes,
    55  		build:                 c.Globals.Manifest.File.Scripts.Build,
    56  		config:                c.Globals.Config.Language.Rust,
    57  		env:                   c.Globals.Manifest.File.Scripts.EnvVars,
    58  		errlog:                c.Globals.ErrLog,
    59  		input:                 in,
    60  		manifestFilename:      manifestFilename,
    61  		metadataFilterEnvVars: c.MetadataFilterEnvVars,
    62  		nonInteractive:        c.Globals.Flags.NonInteractive,
    63  		output:                out,
    64  		postBuild:             c.Globals.Manifest.File.Scripts.PostBuild,
    65  		spinner:               spinner,
    66  		timeout:               c.Flags.Timeout,
    67  		verbose:               c.Globals.Verbose(),
    68  	}
    69  }
    70  
    71  // Rust implements a Toolchain for the Rust language.
    72  type Rust struct {
    73  	Shell
    74  
    75  	// autoYes is the --auto-yes flag.
    76  	autoYes bool
    77  	// build is a shell command defined in fastly.toml using [scripts.build].
    78  	build string
    79  	// config is the Rust specific application configuration.
    80  	config config.Rust
    81  	// defaultBuild indicates if the default build script was used.
    82  	defaultBuild bool
    83  	// env is environment variables to be set.
    84  	env []string
    85  	// errlog is an abstraction for recording errors to disk.
    86  	errlog fsterr.LogInterface
    87  	// input is the user's terminal stdin stream
    88  	input io.Reader
    89  	// manifestFilename is the name of the manifest file.
    90  	manifestFilename string
    91  	// metadataFilterEnvVars is a comma-separated list of user defined env vars.
    92  	metadataFilterEnvVars string
    93  	// nonInteractive is the --non-interactive flag.
    94  	nonInteractive bool
    95  	// output is the users terminal stdout stream
    96  	output io.Writer
    97  	// packageName is the resolved package name from the project Cargo.toml
    98  	packageName string
    99  	// postBuild is a custom script executed after the build but before the Wasm
   100  	// binary is added to the .tar.gz archive.
   101  	postBuild string
   102  	// projectRoot is the root directory where the Cargo.toml is located.
   103  	projectRoot string
   104  	// spinner is a terminal progress status indicator.
   105  	spinner text.Spinner
   106  	// timeout is the build execution threshold.
   107  	timeout int
   108  	// verbose indicates if the user set --verbose
   109  	verbose bool
   110  }
   111  
   112  // DefaultBuildScript indicates if a custom build script was used.
   113  func (r *Rust) DefaultBuildScript() bool {
   114  	return r.defaultBuild
   115  }
   116  
   117  // CargoLockFilePackage represents a package within a Rust lockfile.
   118  type CargoLockFilePackage struct {
   119  	Name    string `toml:"name"`
   120  	Version string `toml:"version"`
   121  }
   122  
   123  // CargoLockFile represents a Rust lockfile.
   124  type CargoLockFile struct {
   125  	Packages []CargoLockFilePackage `toml:"package"`
   126  }
   127  
   128  // Dependencies returns all dependencies used by the project.
   129  func (r *Rust) Dependencies() map[string]string {
   130  	deps := make(map[string]string)
   131  
   132  	var clf CargoLockFile
   133  	if data, err := os.ReadFile("Cargo.lock"); err == nil {
   134  		if err := toml.Unmarshal(data, &clf); err == nil {
   135  			for _, v := range clf.Packages {
   136  				deps[v.Name] = v.Version
   137  			}
   138  		}
   139  	}
   140  
   141  	return deps
   142  }
   143  
   144  // Build compiles the user's source code into a Wasm binary.
   145  func (r *Rust) Build() error {
   146  	if r.build == "" {
   147  		r.build = fmt.Sprintf(RustDefaultBuildCommand, RustDefaultPackageName)
   148  		r.defaultBuild = true
   149  	}
   150  
   151  	err := r.modifyCargoPackageName(r.defaultBuild)
   152  	if err != nil {
   153  		return err
   154  	}
   155  
   156  	if r.defaultBuild && r.verbose {
   157  		text.Info(r.output, "No [scripts.build] found in %s. The following default build command for Rust will be used: `%s`\n\n", r.manifestFilename, r.build)
   158  	}
   159  
   160  	r.toolchainConstraint()
   161  
   162  	bt := BuildToolchain{
   163  		autoYes:                   r.autoYes,
   164  		buildFn:                   r.Shell.Build,
   165  		buildScript:               r.build,
   166  		env:                       r.env,
   167  		errlog:                    r.errlog,
   168  		in:                        r.input,
   169  		internalPostBuildCallback: r.ProcessLocation,
   170  		manifestFilename:          r.manifestFilename,
   171  		metadataFilterEnvVars:     r.metadataFilterEnvVars,
   172  		nonInteractive:            r.nonInteractive,
   173  		out:                       r.output,
   174  		postBuild:                 r.postBuild,
   175  		spinner:                   r.spinner,
   176  		timeout:                   r.timeout,
   177  		verbose:                   r.verbose,
   178  	}
   179  
   180  	return bt.Build()
   181  }
   182  
   183  // RustToolchainManifest models a [toolchain] from a rust-toolchain.toml manifest.
   184  type RustToolchainManifest struct {
   185  	Toolchain RustToolchain `toml:"toolchain"`
   186  }
   187  
   188  // RustToolchain models the rust-toolchain targets.
   189  type RustToolchain struct {
   190  	Targets []string `toml:"targets"`
   191  }
   192  
   193  // modifyCargoPackageName validates whether the --bin flag matches the
   194  // Cargo.toml package name. If it doesn't match, update the default build script
   195  // to match.
   196  func (r *Rust) modifyCargoPackageName(noBuildScript bool) error {
   197  	s := "cargo locate-project --quiet"
   198  	args := strings.Split(s, " ")
   199  
   200  	var stdout, stderr bytes.Buffer
   201  
   202  	// gosec flagged this:
   203  	// G204 (CWE-78): Subprocess launched with variable
   204  	// Disabling as we control this command.
   205  	// #nosec
   206  	// nosemgrep
   207  	cmd := exec.Command(args[0], args[1:]...)
   208  	cmd.Stdout = &stdout
   209  	cmd.Stderr = &stderr
   210  
   211  	err := cmd.Run()
   212  	if err != nil {
   213  		if stderr.Len() > 0 {
   214  			err = fmt.Errorf("%w: %s", err, stderr.String())
   215  		}
   216  		return fmt.Errorf("failed to execute command '%s': %w", s, err)
   217  	}
   218  
   219  	if r.verbose {
   220  		text.Output(r.output, "Command output for '%s': %s", s, stdout.String())
   221  	}
   222  
   223  	var cp *CargoLocateProject
   224  	err = json.Unmarshal(stdout.Bytes(), &cp)
   225  	if err != nil {
   226  		return fmt.Errorf("failed to unmarshal manifest project root metadata: %w", err)
   227  	}
   228  
   229  	r.projectRoot = cp.Root
   230  
   231  	var m CargoManifest
   232  	if err := m.Read(cp.Root); err != nil {
   233  		return fmt.Errorf("error reading %s manifest: %w", RustManifest, err)
   234  	}
   235  
   236  	hasCustomBuildScript := !noBuildScript
   237  
   238  	switch {
   239  	case m.Package.Name != "":
   240  		// If using standard project structure.
   241  		// Cargo.toml won't be a Workspace, so it will contain a package name.
   242  		r.packageName = m.Package.Name
   243  	case len(m.Workspace.Members) > 0 && noBuildScript:
   244  		// If user has a Cargo Workspace AND no custom script.
   245  		// We need to identify which Workspace package is their application.
   246  		// Then extract the package name from its Cargo.toml manifest.
   247  		// We do this by checking for a rust-toolchain.toml containing a wasm32-wasi target.
   248  		//
   249  		// NOTE: This logic will need to change in the future.
   250  		// Specifically, when we support linking multiple Wasm binaries.
   251  		for _, m := range m.Workspace.Members {
   252  			var rtm RustToolchainManifest
   253  			rustToolchainFile := "rust-toolchain.toml"
   254  			data, err := os.ReadFile(filepath.Join(m, rustToolchainFile)) // #nosec G304 (CWE-22)
   255  			if err != nil {
   256  				return err
   257  			}
   258  			err = toml.Unmarshal(data, &rtm)
   259  			if err != nil {
   260  				return fmt.Errorf("failed to unmarshal '%s' data: %w", rustToolchainFile, err)
   261  			}
   262  			if len(rtm.Toolchain.Targets) > 0 && rtm.Toolchain.Targets[0] == "wasm32-wasi" {
   263  				var cm CargoManifest
   264  				err := cm.Read(filepath.Join(m, "Cargo.toml"))
   265  				if err != nil {
   266  					return err
   267  				}
   268  				r.packageName = cm.Package.Name
   269  			}
   270  		}
   271  	case len(m.Workspace.Members) > 0 && hasCustomBuildScript:
   272  		// If user has a Cargo Workspace AND a custom script.
   273  		// Trust their custom script aligns with the relevant Workspace package name.
   274  		// i.e. we parse the package name specified in their custom script.
   275  		parts := strings.Split(r.build, " ")
   276  		for i, p := range parts {
   277  			if p == "--bin" {
   278  				r.packageName = parts[i+1]
   279  				break
   280  			}
   281  		}
   282  	}
   283  
   284  	// Ensure the default build script matches the Cargo.toml package name.
   285  	if noBuildScript && r.packageName != "" && r.packageName != RustDefaultPackageName {
   286  		r.build = fmt.Sprintf(RustDefaultBuildCommand, r.packageName)
   287  	}
   288  
   289  	return nil
   290  }
   291  
   292  // toolchainConstraint warns the user if the required constraint is not met.
   293  //
   294  // NOTE: We don't stop the build as their toolchain may compile successfully.
   295  // The warning is to help a user know something isn't quite right and gives them
   296  // the opportunity to do something about it if they choose.
   297  func (r *Rust) toolchainConstraint() {
   298  	if r.verbose {
   299  		text.Info(r.output, "The Fastly CLI requires a Rust version '%s'.\n\n", r.config.ToolchainConstraint)
   300  	}
   301  
   302  	versionCommand := "cargo version --quiet"
   303  	args := strings.Split(versionCommand, " ")
   304  
   305  	// gosec flagged this:
   306  	// G204 (CWE-78): Subprocess launched with function call as argument or cmd arguments
   307  	// Disabling as we trust the source of the variable.
   308  	// #nosec
   309  	// nosemgrep
   310  	cmd := exec.Command(args[0], args[1:]...)
   311  	stdoutStderr, err := cmd.CombinedOutput()
   312  	output := string(stdoutStderr)
   313  	if err != nil {
   314  		return
   315  	}
   316  
   317  	versionPattern := regexp.MustCompile(`cargo (?P<version>\d[^\s]+)`)
   318  	match := versionPattern.FindStringSubmatch(output)
   319  	if len(match) < 2 { // We expect a pattern with one capture group.
   320  		return
   321  	}
   322  	version := match[1]
   323  
   324  	v, err := semver.NewVersion(version)
   325  	if err != nil {
   326  		return
   327  	}
   328  
   329  	c, err := semver.NewConstraint(r.config.ToolchainConstraint)
   330  	if err != nil {
   331  		return
   332  	}
   333  
   334  	if !c.Check(v) {
   335  		text.Warning(r.output, "The Rust version '%s' didn't meet the constraint '%s'\n\n", version, r.config.ToolchainConstraint)
   336  	}
   337  }
   338  
   339  // ProcessLocation ensures the generated Rust Wasm binary is moved to the
   340  // required location for packaging.
   341  func (r *Rust) ProcessLocation() error {
   342  	dir, err := os.Getwd()
   343  	if err != nil {
   344  		r.errlog.Add(err)
   345  		return fmt.Errorf("getting current working directory: %w", err)
   346  	}
   347  
   348  	var metadata CargoMetadata
   349  	if err := metadata.Read(r.errlog); err != nil {
   350  		r.errlog.Add(err)
   351  		return fmt.Errorf("error reading cargo metadata: %w", err)
   352  	}
   353  
   354  	src := filepath.Join(metadata.TargetDirectory, r.config.WasmWasiTarget, "release", fmt.Sprintf("%s.wasm", r.packageName))
   355  	dst := filepath.Join(dir, "bin", "main.wasm")
   356  
   357  	err = filesystem.CopyFile(src, dst)
   358  	if err != nil {
   359  		r.errlog.Add(err)
   360  		return fmt.Errorf("failed to copy wasm binary: %w", err)
   361  	}
   362  	return nil
   363  }
   364  
   365  // CargoLocateProject represents the metadata for where to find the project's
   366  // Cargo.toml manifest file.
   367  type CargoLocateProject struct {
   368  	Root string `json:"root"`
   369  }
   370  
   371  // CargoManifest models the package configuration properties of a Rust Cargo
   372  // manifest which we are interested in and are read from the Cargo.toml manifest
   373  // file within the $PWD of the package.
   374  type CargoManifest struct {
   375  	Package   CargoPackage   `toml:"package"`
   376  	Workspace CargoWorkspace `toml:"workspace"`
   377  }
   378  
   379  // Read the contents of the Cargo.toml manifest from filename.
   380  func (m *CargoManifest) Read(path string) error {
   381  	// gosec flagged this:
   382  	// G304 (CWE-22): Potential file inclusion via variable.
   383  	// Disabling as we need to load the Cargo.toml from the user's file system.
   384  	// This file is decoded into a predefined struct, any unrecognised fields are dropped.
   385  	// #nosec
   386  	data, err := os.ReadFile(path)
   387  	if err != nil {
   388  		return err
   389  	}
   390  	return toml.Unmarshal(data, m)
   391  }
   392  
   393  // CargoWorkspace models the [workspace] config inside Cargo.toml.
   394  type CargoWorkspace struct {
   395  	Members []string `toml:"members" json:"members"`
   396  }
   397  
   398  // CargoPackage models the package configuration properties of a Rust Cargo
   399  // package which we are interested in and is embedded within CargoManifest and
   400  // CargoLock.
   401  type CargoPackage struct {
   402  	Name    string `toml:"name" json:"name"`
   403  	Version string `toml:"version" json:"version"`
   404  }
   405  
   406  // CargoMetadata models information about the workspace members and resolved
   407  // dependencies of the current package via `cargo metadata` command output.
   408  type CargoMetadata struct {
   409  	Package         []CargoMetadataPackage `json:"packages"`
   410  	TargetDirectory string                 `json:"target_directory"`
   411  }
   412  
   413  // Read the contents of the Cargo.lock file from filename.
   414  func (m *CargoMetadata) Read(errlog fsterr.LogInterface) error {
   415  	cmd := exec.Command("cargo", "metadata", "--quiet", "--format-version", "1")
   416  	stdoutStderr, err := cmd.CombinedOutput()
   417  	if err != nil {
   418  		if len(stdoutStderr) > 0 {
   419  			err = fmt.Errorf("%s", strings.TrimSpace(string(stdoutStderr)))
   420  		}
   421  		errlog.Add(err)
   422  		return err
   423  	}
   424  	r := bytes.NewReader(stdoutStderr)
   425  	if err := json.NewDecoder(r).Decode(&m); err != nil {
   426  		errlog.Add(err)
   427  		return err
   428  	}
   429  	return nil
   430  }
   431  
   432  // CargoMetadataPackage models the package structure returned when executing
   433  // the command `cargo metadata`.
   434  type CargoMetadataPackage struct {
   435  	Name         string                 `toml:"name" json:"name"`
   436  	Version      string                 `toml:"version" json:"version"`
   437  	Dependencies []CargoMetadataPackage `toml:"dependencies" json:"dependencies"`
   438  }