| // Copyright 2026 The Fuchsia Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| package ffx |
| |
| import ( |
| "cmp" |
| "context" |
| "encoding/json" |
| "fmt" |
| "io" |
| "net" |
| "os" |
| "os/exec" |
| "path/filepath" |
| "slices" |
| "strconv" |
| "strings" |
| "time" |
| |
| "go.fuchsia.dev/fuchsia/tools/lib/ffxutil" |
| "go.fuchsia.dev/fuchsia/tools/lib/logger" |
| ) |
| |
| var _ FFXToolImpl = (*ffxStrict)(nil) |
| |
| type ffxStrict struct { |
| ffxToolPath string |
| runDir RunDir |
| supportsPackageBlob *bool |
| ffxInstance *ffxutil.FFXInstance |
| hasPlaceholderKey bool |
| } |
| |
| func newFfxStrict(ctx context.Context, ffxToolPath string, runDir RunDir) (*ffxStrict, error) { |
| if _, err := os.Stat(ffxToolPath); err != nil { |
| return nil, fmt.Errorf("error accessing %v: %w", ffxToolPath, err) |
| } |
| |
| outputDir := filepath.Join(runDir.path, "strict-output") |
| if err := os.MkdirAll(outputDir, 0755); err != nil { |
| return nil, fmt.Errorf("failed to create strict output dir: %w", err) |
| } |
| |
| var sshInfo *ffxutil.SSHInfo |
| privKey := runDir.PrivKey() |
| if privKey == "" { |
| privKey = os.Getenv("FUCHSIA_SSH_KEY") |
| } |
| |
| hasPlaceholderKey := false |
| if privKey != "" { |
| sshInfo = &ffxutil.SSHInfo{ |
| SshPriv: privKey, |
| SshPub: privKey + ".pub", |
| } |
| } else { |
| placeholderPrivPath := filepath.Join(outputDir, "placeholder-priv") |
| err := os.WriteFile(placeholderPrivPath, []byte("placeholder"), 0600) |
| if err != nil { |
| os.RemoveAll(outputDir) |
| return nil, err |
| } |
| sshInfo = &ffxutil.SSHInfo{ |
| SshPriv: placeholderPrivPath, |
| } |
| hasPlaceholderKey = true |
| } |
| |
| ffxInst, err := ffxutil.NewFFXInstance(ctx, ffxToolPath, "", []string{}, "", sshInfo, outputDir, ffxutil.UseFFXStrict) |
| if err != nil { |
| os.RemoveAll(outputDir) |
| return nil, err |
| } |
| |
| return &ffxStrict{ |
| ffxToolPath: ffxToolPath, |
| runDir: runDir, |
| supportsPackageBlob: nil, |
| ffxInstance: ffxInst, |
| hasPlaceholderKey: hasPlaceholderKey, |
| }, nil |
| } |
| |
| func (f *ffxStrict) SetTarget(target string) { |
| f.ffxInstance.SetTarget(target) |
| } |
| |
| func (f *ffxStrict) ConfigSet(ctx context.Context, key, value string) error { |
| return f.ffxInstance.ConfigSet(ctx, key, value) |
| } |
| |
| func (f *ffxStrict) RunDir() RunDir { |
| return f.runDir |
| } |
| |
| func (f *ffxStrict) ClearRunDir() { |
| os.RemoveAll(f.RunDir().path) |
| } |
| |
| // resolveTargetIfNeeded resolves a target name to an IP address if it is not already a valid |
| // strict mode target specifier (e.g. IP address, serial, usb). This ensures that strict mode |
| // commands do not fail immediately when passed a node name. |
| func (f *ffxStrict) resolveTargetIfNeeded(ctx context.Context, target string) (string, error) { |
| if target == "" { |
| return "", nil |
| } |
| |
| // Attempt to split host and port if present. |
| host := target |
| if h, _, err := net.SplitHostPort(target); err == nil { |
| host = h |
| } |
| |
| // Strip brackets and scope ID to validate if the target is an IP address. |
| ipStr := strings.Trim(host, "[]") |
| if idx := strings.Index(ipStr, "%"); idx != -1 { |
| ipStr = ipStr[:idx] |
| } |
| |
| // Check if target is a valid IP address, or a valid prefix. |
| if net.ParseIP(ipStr) != nil || strings.HasPrefix(target, "usb:") || strings.HasPrefix(target, "vsock:") || strings.HasPrefix(target, "serial:") { |
| return target, nil |
| } |
| |
| // Target looks like a node name. Resolve it using TargetList. |
| entries, err := f.targetListInternal(ctx, target, 0) |
| if err != nil { |
| return "", fmt.Errorf("failed to resolve node name %q to an IP address: %w", target, err) |
| } |
| |
| if len(entries) == 0 { |
| return "", fmt.Errorf("no target found for node name %q", target) |
| } |
| |
| for _, addr := range entries[0].Addresses { |
| if addr.Type == "Ip" { |
| return addr.IP, nil |
| } |
| } |
| |
| return "", fmt.Errorf("no IP address found for node name %q", target) |
| } |
| |
| func (f *ffxStrict) TargetList(ctx context.Context) ([]TargetEntry, error) { |
| return f.targetListInternal(ctx, "", 0) |
| } |
| |
| func (f *ffxStrict) targetListInternal(ctx context.Context, target string, timeout time.Duration) ([]TargetEntry, error) { |
| args := []string{ |
| "--machine", |
| "json", |
| "target", |
| "list", |
| } |
| |
| if timeout > 0 { |
| args = append([]string{"-c", fmt.Sprintf("discovery.timeout=%d", timeout.Milliseconds())}, args...) |
| } |
| |
| if target != "" { |
| args = append(args, target) |
| } |
| |
| stdout, err := f.runFFXCmd(ctx, args...) |
| if err != nil { |
| return []TargetEntry{}, fmt.Errorf("ffx target list failed: %w", err) |
| } |
| |
| if len(stdout) == 0 { |
| return []TargetEntry{}, nil |
| } |
| |
| var entries []TargetEntry |
| if err := json.Unmarshal(stdout, &entries); err != nil { |
| return []TargetEntry{}, err |
| } |
| |
| return entries, nil |
| } |
| |
| func (f *ffxStrict) GetDisambiguatedTarget(ctx context.Context) (TargetEntry, error) { |
| targets, err := f.targetListInternal(ctx, "", 0) |
| if err != nil { |
| return TargetEntry{}, err |
| } |
| |
| if len(targets) == 1 { |
| return targets[0], nil |
| } |
| |
| for _, v := range targets { |
| if v.IsDefault { |
| return v, nil |
| } |
| } |
| |
| if len(targets) == 0 { |
| return TargetEntry{}, fmt.Errorf("no targets found") |
| } |
| |
| return slices.MinFunc(targets, func(a, b TargetEntry) int { |
| return cmp.Compare(a.NodeName, b.NodeName) |
| }), nil |
| } |
| |
| func (f *ffxStrict) TargetListForNode(ctx context.Context, nodeName string) ([]TargetEntry, error) { |
| entries, err := f.targetListInternal(ctx, nodeName, 0) |
| if err != nil { |
| return []TargetEntry{}, err |
| } |
| |
| var matchingTargets []TargetEntry |
| |
| for _, target := range entries { |
| if target.NodeName == nodeName { |
| matchingTargets = append(matchingTargets, target) |
| } |
| } |
| |
| return matchingTargets, nil |
| } |
| |
| func (f *ffxStrict) WaitForTarget(ctx context.Context, address string) (TargetEntry, error) { |
| for attempt := 0; attempt < 10; attempt++ { |
| entries, err := f.targetListInternal(ctx, "", 0) |
| if err != nil { |
| return TargetEntry{}, fmt.Errorf("failed to get target list: %w", err) |
| } |
| |
| for _, target := range entries { |
| for _, addr := range target.Addresses { |
| if addr.Type == "Ip" && addr.IP == address { |
| return target, nil |
| } |
| } |
| } |
| time.Sleep(5 * time.Second) |
| } |
| |
| return TargetEntry{}, fmt.Errorf("no target found for address %v", address) |
| } |
| |
| func (f *ffxStrict) SupportsZedbootDiscovery(ctx context.Context) (bool, error) { |
| args := []string{ |
| "config", |
| "get", |
| "discovery.zedboot.enabled", |
| } |
| |
| stdout, err := f.runFFXCmd(ctx, args...) |
| if err != nil { |
| if exiterr, ok := err.(*exec.ExitError); ok { |
| if exiterr.ExitCode() == 2 { |
| return false, nil |
| } |
| } |
| |
| return false, fmt.Errorf("ffx config get failed: %w", err) |
| } |
| |
| if string(stdout) == "true\n" { |
| return true, nil |
| } |
| |
| return false, nil |
| } |
| |
| func (f *ffxStrict) TargetGetSshTime(ctx context.Context, target string) (time.Duration, error) { |
| resolvedTarget, err := f.resolveTargetIfNeeded(ctx, target) |
| if err != nil { |
| return 0, err |
| } |
| |
| args := []string{ |
| "--target", |
| resolvedTarget, |
| "target", |
| "get-time", |
| } |
| |
| t0 := time.Now() |
| stdout, err := f.runFFXCmd(ctx, args...) |
| t1 := time.Now() |
| |
| if err != nil { |
| return 0, fmt.Errorf("ffx target get-time failed: %w", err) |
| } |
| |
| t, err := strconv.Atoi(strings.TrimSpace(string(stdout))) |
| if err != nil { |
| return 0, fmt.Errorf("failed to parse ffx target-get-time output: %w", err) |
| } |
| |
| latency := t1.Sub(t0) / 2 |
| monotonicTime := (time.Duration(t) * time.Nanosecond) - latency |
| |
| return monotonicTime, nil |
| } |
| |
| func (f *ffxStrict) TargetUpdateChannelSet(ctx context.Context, target string, channel string) error { |
| resolvedTarget, err := f.resolveTargetIfNeeded(ctx, target) |
| if err != nil { |
| return err |
| } |
| |
| args := []string{ |
| "--machine", |
| "raw", |
| "--target", |
| resolvedTarget, |
| "target", |
| "update", |
| "channel", |
| "set", |
| channel, |
| } |
| |
| _, err = f.runFFXCmd(ctx, args...) |
| return err |
| } |
| |
| func (f *ffxStrict) TargetUpdateCheckNowMonitor(ctx context.Context, target string) ([]byte, error) { |
| resolvedTarget, err := f.resolveTargetIfNeeded(ctx, target) |
| if err != nil { |
| return nil, err |
| } |
| |
| args := []string{ |
| "--target", |
| resolvedTarget, |
| "target", |
| "update", |
| "check-now", |
| "--monitor", |
| } |
| |
| return f.runFFXCmd(ctx, args...) |
| } |
| |
| func (f *ffxStrict) TargetUpdateForceInstallNoReboot(ctx context.Context, target string, url string) error { |
| resolvedTarget, err := f.resolveTargetIfNeeded(ctx, target) |
| if err != nil { |
| return err |
| } |
| |
| args := []string{ |
| "--target", |
| resolvedTarget, |
| "target", |
| "update", |
| "force-install", |
| url, |
| "--reboot", |
| "false", |
| } |
| |
| _, err = f.runFFXCmd(ctx, args...) |
| return err |
| } |
| |
| func (f *ffxStrict) RebootToBootloader(ctx context.Context, target string, runSSH func(ctx context.Context, cmd []string, stdout io.Writer, stderr io.Writer) error) error { |
| // Note: runSSH is unused in strict mode because we use "ffx target reboot -b" instead. |
| // It is kept to satisfy the interface that is introduced in a follow-up CL. |
| resolvedTarget, err := f.resolveTargetIfNeeded(ctx, target) |
| if err != nil { |
| return err |
| } |
| _, err = f.runFFXCmd(ctx, "--target", resolvedTarget, "target", "reboot", "-b") |
| return err |
| } |
| |
| func (f *ffxStrict) Flasher() *Flasher { |
| return newFlasher(f) |
| } |
| |
| func containsSequence(args []string, seq []string) bool { |
| for i := 0; i <= len(args)-len(seq); i++ { |
| match := true |
| for j := 0; j < len(seq); j++ { |
| if args[i+j] != seq[j] { |
| match = false |
| break |
| } |
| } |
| if match { |
| return true |
| } |
| } |
| return false |
| } |
| |
| func isTargetAgnostic(args []string) bool { |
| if containsSequence(args, []string{"config"}) { |
| return true |
| } |
| if containsSequence(args, []string{"package", "blob", "decompress"}) { |
| return true |
| } |
| if containsSequence(args, []string{"repository", "create"}) { |
| return true |
| } |
| if containsSequence(args, []string{"repository", "publish"}) { |
| return true |
| } |
| if containsSequence(args, []string{"target", "list"}) { |
| return true |
| } |
| if containsSequence(args, []string{"target", "discover"}) { |
| return true |
| } |
| if containsSequence(args, []string{"target", "flash"}) { |
| return true |
| } |
| if containsSequence(args, []string{"target", "fastboot"}) { |
| return true |
| } |
| if containsSequence(args, []string{"emulator"}) { |
| return true |
| } |
| if containsSequence(args, []string{"monitor"}) { |
| return true |
| } |
| |
| return false |
| } |
| |
| func (f *ffxStrict) runFFXCmd(ctx context.Context, args ...string) ([]byte, error) { |
| path, err := exec.LookPath(f.ffxToolPath) |
| if err != nil { |
| return []byte{}, err |
| } |
| |
| if f.hasPlaceholderKey && !isTargetAgnostic(args) { |
| return nil, fmt.Errorf("cannot run target-interacting command without a valid SSH key (args: %v)", args) |
| } |
| |
| // Add default flags to match daemon implementation |
| args = append([]string{"--log-level", "trace"}, args...) |
| |
| logger.Infof(ctx, "running with strict ffx: %s %v", path, args) |
| stdoutStr, err := f.ffxInstance.RunAndGetOutput(ctx, args...) |
| stdout := []byte(stdoutStr) |
| |
| if err == nil { |
| logger.Infof(ctx, "finished running with strict ffx %s %q", path, args) |
| } else { |
| logger.Infof(ctx, "running with strict ffx %s %q failed with: %v", path, args, err) |
| } |
| return stdout, err |
| } |
| |
| func (f *ffxStrict) RunAndGetOutput(ctx context.Context, args ...string) (string, error) { |
| stdout, err := f.runFFXCmd(ctx, args...) |
| return string(stdout), err |
| } |
| |
| func (f *ffxStrict) Run(ctx context.Context, args ...string) error { |
| _, err := f.RunAndGetOutput(ctx, args...) |
| return err |
| } |
| |
| func (f *ffxStrict) RepositoryCreate(ctx context.Context, repoDir, keysDir string) error { |
| args := []string{ |
| "--config", "ffx_repository=true", |
| "repository", |
| "create", |
| "--keys", keysDir, |
| repoDir, |
| } |
| |
| _, err := f.runFFXCmd(ctx, args...) |
| return err |
| } |
| |
| func (f *ffxStrict) RepositoryPublish(ctx context.Context, repoDir string, packageManifests []string, additionalArgs ...string) error { |
| args := []string{ |
| "repository", |
| "publish", |
| } |
| |
| for _, manifest := range packageManifests { |
| args = append(args, "--package", manifest) |
| } |
| |
| args = append(args, additionalArgs...) |
| args = append(args, repoDir) |
| |
| _, err := f.runFFXCmd(ctx, args...) |
| return err |
| } |
| |
| func (f *ffxStrict) SupportsPackageBlob(ctx context.Context) bool { |
| if f.supportsPackageBlob == nil { |
| _, err := f.runFFXCmd(ctx, "package", "blob", "--help") |
| supportsPackageBlob := err == nil |
| f.supportsPackageBlob = &supportsPackageBlob |
| } |
| return *f.supportsPackageBlob |
| } |
| |
| func (f *ffxStrict) DecompressBlobs(ctx context.Context, delivery_blobs []string, out_dir string) error { |
| args := []string{ |
| "package", |
| "blob", |
| "decompress", |
| "--output", out_dir, |
| } |
| |
| args = append(args, delivery_blobs...) |
| |
| _, err := f.runFFXCmd(ctx, args...) |
| return err |
| } |
| |
| func (f *ffxStrict) RegisterPackageRepository(ctx context.Context, repo_url string) error { |
| args := []string{ |
| "target", |
| "repository", |
| "register", |
| "--json-uri", |
| repo_url, |
| } |
| |
| _, err := f.runFFXCmd(ctx, args...) |
| return err |
| } |
| |
| func (f *ffxStrict) TargetGetLastRebootReason(ctx context.Context, target string) (string, error) { |
| resolvedTarget, err := f.resolveTargetIfNeeded(ctx, target) |
| if err != nil { |
| return "", err |
| } |
| |
| logger.Infof(ctx, "getting last reboot reason for target %s", resolvedTarget) |
| f.ffxInstance.SetTarget(resolvedTarget) |
| return f.ffxInstance.GetLastRebootReason(ctx) |
| } |
| |
| func (f *ffxStrict) GetTarget() string { |
| return f.ffxInstance.GetTarget() |
| } |
| |
| func (f *ffxStrict) TargetGetSshAddress(ctx context.Context, target string) (string, error) { |
| return f.resolveTargetIfNeeded(ctx, target) |
| } |
| |
| func (f *ffxStrict) StopDaemon(ctx context.Context) error { |
| return nil |
| } |
| |
| func (f *ffxStrict) TargetAdd(ctx context.Context, target string) error { |
| return fmt.Errorf("TargetAdd not supported in strict mode") |
| } |