fetchAndSaveRemoteResources fetches files listed in the top-level "resources" frontmatter field from the same remote repository and saves them locally. Resources are resolved as relative paths from the same directory as the source workflow in the remote repo. GitHub Actions expression syntax (e.g.
(content string, spec *WorkflowSpec, targetDir string, verbose bool, force bool, tracker *FileTracker)
| 68 | // For non-Markdown resource files: if the target already exists and force is false, an error |
| 69 | // is returned regardless of origin (non-markdown files have no source tracking). |
| 70 | func fetchAndSaveRemoteResources(content string, spec *WorkflowSpec, targetDir string, verbose bool, force bool, tracker *FileTracker) error { |
| 71 | if spec.RepoSlug == "" { |
| 72 | return nil |
| 73 | } |
| 74 | |
| 75 | parts := strings.SplitN(spec.RepoSlug, "/", 2) |
| 76 | if len(parts) != 2 { |
| 77 | return nil |
| 78 | } |
| 79 | owner, repo := parts[0], parts[1] |
| 80 | ref := spec.Version |
| 81 | if ref == "" { |
| 82 | defaultBranch, err := getRepoDefaultBranch(context.Background(), spec.RepoSlug) |
| 83 | if err != nil { |
| 84 | remoteWorkflowLog.Printf("Failed to resolve default branch for %s, falling back to 'main': %v", spec.RepoSlug, err) |
| 85 | ref = "main" |
| 86 | } else { |
| 87 | ref = defaultBranch |
| 88 | } |
| 89 | spec.Version = ref |
| 90 | } |
| 91 | |
| 92 | resourcePaths, err := extractResources(content) |
| 93 | if err != nil { |
| 94 | return err |
| 95 | } |
| 96 | if len(resourcePaths) == 0 { |
| 97 | return nil |
| 98 | } |
| 99 | |
| 100 | // Resources are resolved relative to the source workflow's directory in the remote repo. |
| 101 | workflowBaseDir := getParentDir(spec.WorkflowPath) |
| 102 | |
| 103 | // Pre-compute the absolute target directory for path-traversal boundary checks. |
| 104 | absTargetDir, err := filepath.Abs(targetDir) |
| 105 | if err != nil { |
| 106 | remoteWorkflowLog.Printf("Failed to resolve absolute path for target directory %s: %v", targetDir, err) |
| 107 | return nil |
| 108 | } |
| 109 | |
| 110 | for _, resourcePath := range resourcePaths { |
| 111 | // Early rejection of path traversal patterns. This is a fast first-pass check; |
| 112 | // the filepath.Rel boundary check below is the authoritative security control. |
| 113 | if strings.Contains(resourcePath, "..") { |
| 114 | if verbose { |
| 115 | fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Skipping resource with unsafe path: %q", resourcePath))) |
| 116 | } |
| 117 | continue |
| 118 | } |
| 119 | |
| 120 | // Resolve the remote file path |
| 121 | var remoteFilePath string |
| 122 | if rest, ok := strings.CutPrefix(resourcePath, "/"); ok { |
| 123 | remoteFilePath = rest |
| 124 | } else if workflowBaseDir != "" { |
| 125 | remoteFilePath = path.Join(workflowBaseDir, resourcePath) |
| 126 | } else { |
| 127 | remoteFilePath = resourcePath |