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 }