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