11package main
22
33import (
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
307378func 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