Skip to content

Commit dfb0035

Browse files
feat: [CI-18487]: Glob pattern support for PLUGIN_STRIP_PREFIX (#196)
* feat: [CI-18487]: Glob pattern support for PLUGIN_STRIP_PREFIX * feat: [CI-18487]: Formatting * feat: [CI-18487]: Precompile strip_prefix regex once per run, fix absolute-path join in resolveKey, added logs and tests. * feat: [CI-18487]: Formatting
1 parent 6655f98 commit dfb0035

File tree

4 files changed

+610
-12
lines changed

4 files changed

+610
-12
lines changed

README.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,47 @@ docker run --rm \
6161
plugins/s3 --dry-run
6262
```
6363

64+
### Wildcard strip_prefix
65+
66+
You can strip dynamic, runtime-generated directory prefixes from S3 keys using shell-style wildcards in `strip_prefix`.
67+
68+
Supported patterns:
69+
70+
- `*` matches exactly one path segment (no `/`).
71+
- `**` matches any depth (including zero segments).
72+
- `?` matches exactly one character (no `/`).
73+
74+
The pattern is anchored at the start of the path. Use `/` to delimit directory boundaries. We recommend ending directory patterns with a trailing `/` to strip whole directory segments.
75+
76+
Examples:
77+
78+
- Pattern: `/harness/artifacts/*/`
79+
- Path: `/harness/artifacts/build-123/module/app.zip`
80+
- Result key suffix: `module/app.zip`
81+
82+
- Pattern: `/harness/artifacts/**/`
83+
- Path: `/harness/artifacts/build-123/deep/nested/file.zip`
84+
- Result key suffix: `file.zip`
85+
86+
- Pattern: `/harness/artifacts/*/services/`
87+
- Path: `/harness/artifacts/build-123/services/auth/auth.zip`
88+
- Result key suffix: `auth/auth.zip`
89+
90+
Trailing slash semantics:
91+
92+
- A trailing `/` indicates you are stripping up to a directory boundary.
93+
- Without a trailing `/`, the match may end mid-segment. For directory prefix stripping, prefer a trailing `/`.
94+
95+
Windows notes:
96+
97+
- `strip_prefix` must start with `/`. Backslashes are accepted and normalized to `/` internally (e.g. `\\harness\\artifacts\\*/` is allowed).
98+
- Windows drive letters like `C:\\...` are not supported and will be rejected.
99+
100+
Dry-run logging:
101+
102+
- With `--dry-run` or `PLUGIN_DRY_RUN=true`, the plugin logs what would be uploaded.
103+
- Fields include `name` (source), `target` (S3 key), `strip_pattern`, and `removed_prefix` (the portion stripped from the path when the pattern matches).
104+
64105
* For Download
65106
```
66107
docker run --rm \

main.go

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ func main() {
8686
},
8787
cli.StringFlag{
8888
Name: "strip-prefix",
89-
Usage: "used to add or remove a prefix from the source/target path",
89+
Usage: "prefix to strip from source path (supports wildcards: *, **, ?)",
9090
EnvVar: "PLUGIN_STRIP_PREFIX",
9191
},
9292
cli.StringSliceFlag{
@@ -163,7 +163,6 @@ func run(c *cli.Context) error {
163163
_ = godotenv.Load(c.String("env-file"))
164164
}
165165

166-
167166
plugin := Plugin{
168167
Endpoint: c.String("endpoint"),
169168
Key: c.String("access-key"),
@@ -193,4 +192,3 @@ func run(c *cli.Context) error {
193192

194193
return plugin.Exec()
195194
}
196-

plugin.go

Lines changed: 202 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package main
22

33
import (
4+
"fmt"
45
"io"
56
"mime"
67
"os"
@@ -84,7 +85,7 @@ type Plugin struct {
8485
Source string
8586
Target string
8687

87-
// Strip the prefix from the target path
88+
// Strip the prefix from the target path (supports wildcards)
8889
StripPrefix string
8990

9091
// Exclude files matching this pattern.
@@ -138,13 +139,64 @@ func (p *Plugin) Exec() error {
138139
return err
139140
}
140141

142+
// Validate strip prefix pattern and precompile regex once
143+
normalizedStrip := strings.ReplaceAll(p.StripPrefix, "\\", "/")
144+
if p.StripPrefix != "" {
145+
if err := validateStripPrefix(p.StripPrefix); err != nil {
146+
log.WithFields(log.Fields{
147+
"error": err,
148+
"pattern": p.StripPrefix,
149+
}).Error("Invalid strip_prefix pattern")
150+
return err
151+
}
152+
}
153+
154+
var compiled *regexp.Regexp
155+
if normalizedStrip != "" && strings.ContainsAny(normalizedStrip, "*?") {
156+
var err error
157+
compiled, err = patternToRegex(normalizedStrip)
158+
if err != nil {
159+
log.WithFields(log.Fields{
160+
"error": err,
161+
"pattern": p.StripPrefix,
162+
}).Error("Failed to compile strip_prefix pattern")
163+
return err
164+
}
165+
}
166+
167+
anyMatched := false
168+
141169
for _, match := range matches {
142170
// skip directories
143171
if isDir(match, matches) {
144172
continue
145173
}
146174

147-
target := resolveKey(p.Target, match, p.StripPrefix)
175+
// Preview stripping (using precompiled regex when available)
176+
stripped := match
177+
matched := false
178+
if normalizedStrip != "" {
179+
var err error
180+
stripped, matched, err = stripWildcardPrefixWithRegex(match, normalizedStrip, compiled)
181+
if err != nil {
182+
log.WithFields(log.Fields{
183+
"error": err,
184+
"path": match,
185+
"pattern": p.StripPrefix,
186+
}).Warn("Failed to strip prefix, using original path")
187+
stripped = match
188+
}
189+
}
190+
if matched {
191+
anyMatched = true
192+
}
193+
194+
// Build final key (ensure relative component for join)
195+
rel := strings.TrimPrefix(filepath.ToSlash(stripped), "/")
196+
target := filepath.ToSlash(filepath.Join(p.Target, rel))
197+
if !strings.HasPrefix(target, "/") {
198+
target = "/" + target
199+
}
148200

149201
contentType := matchExtension(match, p.ContentType)
150202
contentEncoding := matchExtension(match, p.ContentEncoding)
@@ -165,9 +217,22 @@ func (p *Plugin) Exec() error {
165217
"target": target,
166218
}).Info("Uploading file")
167219

168-
// when executing a dry-run we exit because we don't actually want to
169-
// upload the file to S3.
220+
// when executing a dry-run print what would be stripped and skip upload.
170221
if p.DryRun {
222+
removed := ""
223+
if matched {
224+
// removed prefix = original - stripped suffix
225+
orig := filepath.ToSlash(match)
226+
rem := strings.TrimSuffix(orig, filepath.ToSlash(stripped))
227+
removed = rem
228+
}
229+
log.WithFields(log.Fields{
230+
"name": match,
231+
"bucket": p.Bucket,
232+
"target": target,
233+
"strip_pattern": p.StripPrefix,
234+
"removed_prefix": removed,
235+
}).Info("Dry-run: would upload")
171236
continue
172237
}
173238

@@ -226,6 +291,12 @@ func (p *Plugin) Exec() error {
226291
f.Close()
227292
}
228293

294+
if normalizedStrip != "" && !anyMatched {
295+
log.WithFields(log.Fields{
296+
"pattern": p.StripPrefix,
297+
}).Warn("strip_prefix did not match any paths; keys will include original path")
298+
}
299+
229300
return nil
230301
}
231302

@@ -305,7 +376,21 @@ func assumeRole(roleArn, roleSessionName, externalID string) *credentials.Creden
305376
// resolveKey is a helper function that returns s3 object key where file present at srcPath is uploaded to.
306377
// srcPath is assumed to be in forward slash format
307378
func resolveKey(target, srcPath, stripPrefix string) string {
308-
key := filepath.Join(target, strings.TrimPrefix(srcPath, filepath.ToSlash(stripPrefix)))
379+
// Use wildcard-aware prefix stripping
380+
stripped, err := stripWildcardPrefix(srcPath, stripPrefix)
381+
if err != nil {
382+
// Log error but continue with original path
383+
log.WithFields(log.Fields{
384+
"error": err,
385+
"path": srcPath,
386+
"pattern": stripPrefix,
387+
}).Warn("Failed to strip prefix, using original path")
388+
stripped = srcPath
389+
}
390+
// Ensure we never drop the target when the stripped path is absolute.
391+
// Always join with a relative path component.
392+
stripped = strings.TrimPrefix(stripped, "/")
393+
key := filepath.Join(target, stripped)
309394
key = filepath.ToSlash(key)
310395
if !strings.HasPrefix(key, "/") {
311396
key = "/" + key
@@ -370,7 +455,6 @@ func (p *Plugin) downloadS3Object(client *s3.S3, sourceDir, key, target string)
370455

371456
// Create the destination file path
372457
destination := filepath.Join(p.Target, target)
373-
log.Println("Destination: ", destination)
374458

375459
// Extract the directory from the destination path
376460
dir := filepath.Dir(destination)
@@ -466,7 +550,6 @@ func (p *Plugin) createS3Client() *s3.S3 {
466550
log.Warn("AWS Key and/or Secret not provided (falling back to ec2 instance profile)")
467551
}
468552

469-
470553
// Create session with primary credentials
471554
sess, err = session.NewSession(conf)
472555
if err != nil {
@@ -479,8 +562,8 @@ func (p *Plugin) createS3Client() *s3.S3 {
479562
// Handle secondary role assumption if UserRoleArn is provided
480563
if len(p.UserRoleArn) > 0 {
481564
log.WithField("UserRoleArn", p.UserRoleArn).Info("Using user role ARN")
482-
483-
// Create credentials using the existing session for role assumption
565+
566+
// Create credentials using the existing session for role assumption
484567
// by assuming the UserRoleArn (with ExternalID when provided)
485568
creds := stscreds.NewCredentials(sess, p.UserRoleArn, func(provider *stscreds.AssumeRoleProvider) {
486569
if p.UserRoleExternalID != "" {
@@ -508,3 +591,113 @@ func assumeRoleWithWebIdentity(sess *session.Session, roleArn, roleSessionName,
508591
}
509592
return credentials.NewStaticCredentials(*result.Credentials.AccessKeyId, *result.Credentials.SecretAccessKey, *result.Credentials.SessionToken), nil
510593
}
594+
595+
// validateStripPrefix validates a strip prefix pattern with wildcards
596+
func validateStripPrefix(pattern string) error {
597+
// Normalize Windows backslashes to forward slashes for validation (OS-independent)
598+
pattern = strings.ReplaceAll(pattern, "\\", "/")
599+
600+
// Pattern must start with /
601+
if !strings.HasPrefix(pattern, "/") {
602+
return fmt.Errorf("strip_prefix must start with '/'")
603+
}
604+
605+
// Reject Windows drive-letter prefixes like C:/...
606+
if len(pattern) >= 2 && pattern[1] == ':' {
607+
return fmt.Errorf("strip_prefix must be an absolute POSIX-style path (e.g. '/root/...'), drive letters are not supported")
608+
}
609+
610+
// Check length limit
611+
if len(pattern) > 256 {
612+
return fmt.Errorf("strip_prefix pattern too long (max 256 characters)")
613+
}
614+
615+
// Count wildcards
616+
wildcardCount := strings.Count(pattern, "*") + strings.Count(pattern, "?")
617+
if wildcardCount > 20 {
618+
return fmt.Errorf("strip_prefix pattern contains too many wildcards (max 20)")
619+
}
620+
621+
// Check for empty segments
622+
if strings.Contains(pattern, "//") {
623+
return fmt.Errorf("strip_prefix pattern contains empty segment '//'")
624+
}
625+
626+
// Check for invalid ** usage (must be standalone segment)
627+
parts := strings.Split(pattern, "/")
628+
for _, part := range parts {
629+
if strings.Contains(part, "**") && part != "**" {
630+
return fmt.Errorf("'**' must be a standalone directory segment")
631+
}
632+
}
633+
634+
return nil
635+
}
636+
637+
// patternToRegex converts shell-style wildcards to regex
638+
func patternToRegex(pattern string) (*regexp.Regexp, error) {
639+
// Escape special regex characters except our wildcards
640+
escaped := regexp.QuoteMeta(pattern)
641+
642+
// Replace escaped wildcards with regex equivalents
643+
// Order matters: ** must be replaced before *
644+
escaped = strings.ReplaceAll(escaped, `\*\*`, "(.+)") // ** -> (.+) any depth
645+
escaped = strings.ReplaceAll(escaped, `\*`, "([^/]+)") // * -> ([^/]+) one segment
646+
escaped = strings.ReplaceAll(escaped, `\?`, "([^/])") // ? -> ([^/]) one character
647+
648+
// Anchor at start
649+
escaped = "^" + escaped
650+
651+
return regexp.Compile(escaped)
652+
}
653+
654+
// stripWildcardPrefixWithRegex strips prefix using wildcard pattern matching, reusing
655+
// a precompiled regex when provided. It returns the possibly stripped path, whether
656+
// the pattern matched, and any error if stripping would remove the entire key.
657+
func stripWildcardPrefixWithRegex(path, pattern string, re *regexp.Regexp) (string, bool, error) {
658+
if pattern == "" {
659+
return path, false, nil
660+
}
661+
662+
// Normalize paths to forward slashes (OS-independent)
663+
path = strings.ReplaceAll(path, "\\", "/")
664+
pattern = strings.ReplaceAll(pattern, "\\", "/")
665+
666+
// Literal prefix (no wildcards)
667+
if !strings.ContainsAny(pattern, "*?") {
668+
if !strings.HasPrefix(path, pattern) {
669+
return path, false, nil
670+
}
671+
stripped := strings.TrimPrefix(path, pattern)
672+
if stripped == "" || stripped == "/" || strings.TrimPrefix(stripped, "/") == "" {
673+
return path, true, fmt.Errorf("strip_prefix removes entire path for '%s'", filepath.Base(path))
674+
}
675+
return stripped, true, nil
676+
}
677+
678+
// Wildcard pattern
679+
var err error
680+
if re == nil {
681+
re, err = patternToRegex(pattern)
682+
if err != nil {
683+
return path, false, fmt.Errorf("invalid pattern: %v", err)
684+
}
685+
}
686+
687+
m := re.FindStringSubmatch(path)
688+
if len(m) == 0 {
689+
return path, false, nil
690+
}
691+
full := m[0]
692+
stripped := strings.TrimPrefix(path, full)
693+
if stripped == "" || stripped == "/" || strings.TrimPrefix(stripped, "/") == "" {
694+
return path, true, fmt.Errorf("strip_prefix removes entire path for '%s'", filepath.Base(path))
695+
}
696+
return stripped, true, nil
697+
}
698+
699+
// stripWildcardPrefix strips prefix using wildcard pattern matching
700+
func stripWildcardPrefix(path, pattern string) (string, error) {
701+
stripped, _, err := stripWildcardPrefixWithRegex(path, pattern, nil)
702+
return stripped, err
703+
}

0 commit comments

Comments
 (0)