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,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