Skip to content

Commit b6a0aee

Browse files
Merge pull request #198 from DevanshMathur19/CI-18487
feat: [CI-18487]: Glob pattern support for PLUGIN_STRIP_PREFIX
2 parents d50ece1 + 74df2dc commit b6a0aee

File tree

4 files changed

+675
-11
lines changed

4 files changed

+675
-11
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: 206 additions & 8 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,83 @@ 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 != "" && strings.HasPrefix(normalizedStrip, "/") {
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.HasPrefix(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 (wildcard for absolute patterns, literal for relative patterns)
176+
stripped := match
177+
matched := false
178+
if normalizedStrip != "" {
179+
if strings.HasPrefix(normalizedStrip, "/") {
180+
var err error
181+
stripped, matched, err = stripWildcardPrefixWithRegex(match, normalizedStrip, compiled)
182+
if err != nil {
183+
log.WithFields(log.Fields{
184+
"error": err,
185+
"path": match,
186+
"pattern": p.StripPrefix,
187+
}).Warn("Failed to strip prefix, using original path")
188+
stripped = match
189+
}
190+
} else {
191+
// Backward-compat: literal TrimPrefix for relative strip_prefix (no leading '/')
192+
m := filepath.ToSlash(match)
193+
trimmed := strings.TrimPrefix(m, normalizedStrip)
194+
if trimmed != m {
195+
matched = true
196+
stripped = trimmed
197+
} else {
198+
stripped = match
199+
}
200+
}
201+
}
202+
if matched {
203+
anyMatched = true
204+
}
205+
206+
// Build final key
207+
var target string
208+
if normalizedStrip != "" && !strings.HasPrefix(normalizedStrip, "/") {
209+
// Relative strip_prefix: use master resolveKey behavior
210+
target = resolveKey(p.Target, filepath.ToSlash(match), p.StripPrefix)
211+
} else {
212+
// Absolute strip_prefix (wildcards): join stripped suffix under target
213+
rel := strings.TrimPrefix(filepath.ToSlash(stripped), "/")
214+
target = filepath.ToSlash(filepath.Join(p.Target, rel))
215+
if !strings.HasPrefix(target, "/") {
216+
target = "/" + target
217+
}
218+
}
148219

149220
contentType := matchExtension(match, p.ContentType)
150221
contentEncoding := matchExtension(match, p.ContentEncoding)
@@ -165,9 +236,22 @@ func (p *Plugin) Exec() error {
165236
"target": target,
166237
}).Info("Uploading file")
167238

168-
// when executing a dry-run we exit because we don't actually want to
169-
// upload the file to S3.
239+
// when executing a dry-run print what would be stripped and skip upload.
170240
if p.DryRun {
241+
removed := ""
242+
if matched {
243+
// removed prefix = original - stripped suffix
244+
orig := filepath.ToSlash(match)
245+
rem := strings.TrimSuffix(orig, filepath.ToSlash(stripped))
246+
removed = rem
247+
}
248+
log.WithFields(log.Fields{
249+
"name": match,
250+
"bucket": p.Bucket,
251+
"target": target,
252+
"strip_pattern": p.StripPrefix,
253+
"removed_prefix": removed,
254+
}).Info("Dry-run: would upload")
171255
continue
172256
}
173257

@@ -226,6 +310,12 @@ func (p *Plugin) Exec() error {
226310
f.Close()
227311
}
228312

313+
if normalizedStrip != "" && !anyMatched {
314+
log.WithFields(log.Fields{
315+
"pattern": p.StripPrefix,
316+
}).Warn("strip_prefix did not match any paths; keys will include original path")
317+
}
318+
229319
return nil
230320
}
231321

@@ -370,7 +460,6 @@ func (p *Plugin) downloadS3Object(client *s3.S3, sourceDir, key, target string)
370460

371461
// Create the destination file path
372462
destination := filepath.Join(p.Target, target)
373-
log.Println("Destination: ", destination)
374463

375464
// Extract the directory from the destination path
376465
dir := filepath.Dir(destination)
@@ -466,7 +555,6 @@ func (p *Plugin) createS3Client() *s3.S3 {
466555
log.Warn("AWS Key and/or Secret not provided (falling back to ec2 instance profile)")
467556
}
468557

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

0 commit comments

Comments
 (0)