Skip to content

Commit 1fb1923

Browse files
feat: [CI-18487]: Precompile strip_prefix regex once per run, fix absolute-path join in resolveKey, added logs and tests.
1 parent a00e2c7 commit 1fb1923

File tree

3 files changed

+236
-80
lines changed

3 files changed

+236
-80
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 \

plugin.go

Lines changed: 152 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,8 @@ func (p *Plugin) Exec() error {
139139
return err
140140
}
141141

142-
// Validate strip prefix pattern if it contains wildcards
142+
// Validate strip prefix pattern and precompile regex once
143+
normalizedStrip := strings.ReplaceAll(p.StripPrefix, "\\", "/")
143144
if p.StripPrefix != "" {
144145
if err := validateStripPrefix(p.StripPrefix); err != nil {
145146
log.WithFields(log.Fields{
@@ -150,13 +151,52 @@ func (p *Plugin) Exec() error {
150151
}
151152
}
152153

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+
153169
for _, match := range matches {
154170
// skip directories
155171
if isDir(match, matches) {
156172
continue
157173
}
158174

159-
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+
}
160200

161201
contentType := matchExtension(match, p.ContentType)
162202
contentEncoding := matchExtension(match, p.ContentEncoding)
@@ -177,9 +217,22 @@ func (p *Plugin) Exec() error {
177217
"target": target,
178218
}).Info("Uploading file")
179219

180-
// when executing a dry-run we exit because we don't actually want to
181-
// upload the file to S3.
220+
// when executing a dry-run print what would be stripped and skip upload.
182221
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")
183236
continue
184237
}
185238

@@ -238,6 +291,12 @@ func (p *Plugin) Exec() error {
238291
f.Close()
239292
}
240293

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+
241300
return nil
242301
}
243302

@@ -325,10 +384,12 @@ func resolveKey(target, srcPath, stripPrefix string) string {
325384
"error": err,
326385
"path": srcPath,
327386
"pattern": stripPrefix,
328-
}).Warning("Failed to strip prefix, using original path")
387+
}).Warn("Failed to strip prefix, using original path")
329388
stripped = srcPath
330389
}
331-
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, "/")
332393
key := filepath.Join(target, stripped)
333394
key = filepath.ToSlash(key)
334395
if !strings.HasPrefix(key, "/") {
@@ -341,42 +402,42 @@ func resolveSource(sourceDir, source, stripPrefix string) string {
341402
// Remove the leading sourceDir from the source path
342403
path := strings.TrimPrefix(strings.TrimPrefix(source, sourceDir), "/")
343404

344-
// Add the specified stripPrefix to the resulting path
345-
return stripPrefix + path
405+
// Add the specified stripPrefix to the resulting path
406+
return stripPrefix + path
346407
}
347408

348409
// checks if the source path is a dir
349410
func isDir(source string, matches []string) bool {
350-
stat, err := os.Stat(source)
351-
if err != nil {
352-
return true // should never happen
353-
}
354-
if stat.IsDir() {
355-
count := 0
356-
for _, match := range matches {
357-
if strings.HasPrefix(match, source) {
358-
count++
359-
}
360-
}
361-
if count <= 1 {
362-
log.Warnf("Skipping '%s' since it is a directory. Please use correct glob expression if this is unexpected.", source)
363-
}
364-
return true
365-
}
366-
return false
411+
stat, err := os.Stat(source)
412+
if err != nil {
413+
return true // should never happen
414+
}
415+
if stat.IsDir() {
416+
count := 0
417+
for _, match := range matches {
418+
if strings.HasPrefix(match, source) {
419+
count++
420+
}
421+
}
422+
if count <= 1 {
423+
log.Warnf("Skipping '%s' since it is a directory. Please use correct glob expression if this is unexpected.", source)
424+
}
425+
return true
426+
}
427+
return false
367428
}
368429

369430
// normalizePath converts the path to a forward slash format and trims the prefix.
370431
func normalizePath(path string) string {
371-
return strings.TrimPrefix(filepath.ToSlash(path), "/")
432+
return strings.TrimPrefix(filepath.ToSlash(path), "/")
372433
}
373434

374435
// downloadS3Object downloads a single object from S3
375436
func (p *Plugin) downloadS3Object(client *s3.S3, sourceDir, key, target string) error {
376-
log.WithFields(log.Fields{
377-
"bucket": p.Bucket,
378-
"key": key,
379-
}).Info("Getting S3 object")
437+
log.WithFields(log.Fields{
438+
"bucket": p.Bucket,
439+
"key": key,
440+
}).Info("Getting S3 object")
380441

381442
obj, err := client.GetObject(&s3.GetObjectInput{
382443
Bucket: &p.Bucket,
@@ -394,7 +455,6 @@ func (p *Plugin) downloadS3Object(client *s3.S3, sourceDir, key, target string)
394455

395456
// Create the destination file path
396457
destination := filepath.Join(p.Target, target)
397-
log.Println("Destination: ", destination)
398458

399459
// Extract the directory from the destination path
400460
dir := filepath.Dir(destination)
@@ -534,10 +594,18 @@ func assumeRoleWithWebIdentity(sess *session.Session, roleArn, roleSessionName,
534594

535595
// validateStripPrefix validates a strip prefix pattern with wildcards
536596
func validateStripPrefix(pattern string) error {
537-
// Pattern must start with /
538-
if !strings.HasPrefix(pattern, "/") {
539-
return fmt.Errorf("strip_prefix must start with '/'")
540-
}
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+
}
541609

542610
// Check length limit
543611
if len(pattern) > 256 {
@@ -563,7 +631,7 @@ func validateStripPrefix(pattern string) error {
563631
}
564632
}
565633

566-
return nil
634+
return nil
567635
}
568636

569637
// patternToRegex converts shell-style wildcards to regex
@@ -580,50 +648,56 @@ func patternToRegex(pattern string) (*regexp.Regexp, error) {
580648
// Anchor at start
581649
escaped = "^" + escaped
582650

583-
return regexp.Compile(escaped)
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
584697
}
585698

586699
// stripWildcardPrefix strips prefix using wildcard pattern matching
587700
func stripWildcardPrefix(path, pattern string) (string, error) {
588-
if pattern == "" {
589-
return path, nil
590-
}
591-
592-
// Normalize paths
593-
path = filepath.ToSlash(path)
594-
pattern = filepath.ToSlash(pattern)
595-
596-
// Handle literal prefix (no wildcards)
597-
if !strings.ContainsAny(pattern, "*?") {
598-
stripped := strings.TrimPrefix(path, pattern)
599-
// Validate result for literal prefix
600-
if stripped == "" || stripped == "/" || strings.TrimPrefix(stripped, "/") == "" {
601-
return path, fmt.Errorf("strip_prefix removes entire path for '%s'", filepath.Base(path))
602-
}
603-
return stripped, nil
604-
}
605-
606-
// Convert pattern to regex
607-
re, err := patternToRegex(pattern)
608-
if err != nil {
609-
return path, fmt.Errorf("invalid pattern: %v", err)
610-
}
611-
612-
// Find matches
613-
matches := re.FindStringSubmatch(path)
614-
if len(matches) == 0 {
615-
// No match, return path unchanged
616-
return path, nil
617-
}
618-
619-
// Calculate what to strip (the full match)
620-
fullMatch := matches[0]
621-
stripped := strings.TrimPrefix(path, fullMatch)
622-
623-
// Validate result
624-
if stripped == "" || stripped == "/" || strings.TrimPrefix(stripped, "/") == "" {
625-
return path, fmt.Errorf("strip_prefix removes entire path for '%s'", filepath.Base(path))
626-
}
627-
628-
return stripped, nil
701+
stripped, _, err := stripWildcardPrefixWithRegex(path, pattern, nil)
702+
return stripped, err
629703
}

0 commit comments

Comments
 (0)