@@ -68,7 +68,7 @@ pub fn detect_targets_excluding(workspace_root: &Path, exclude: &[&Path]) -> Rai
6868
6969 if let Ok ( content) = std:: fs:: read_to_string ( & file_path) {
7070 for target in & canonical_targets {
71- if content . contains ( target) {
71+ if contains_target_match ( & content , target) {
7272 found. insert ( target. clone ( ) ) ;
7373 }
7474 }
@@ -151,6 +151,59 @@ pub fn get_rust_target_list() -> RailResult<Vec<String>> {
151151 . ok_or_else ( || RailError :: message ( "Failed to get target list from rustc. Ensure rustc is installed and in PATH." ) )
152152}
153153
154+ /// Check if content contains a target triple as a complete word/token
155+ ///
156+ /// This avoids false positives like matching "thumbv7em-none-eabi" when the
157+ /// file only contains "thumbv7em-none-eabihf". Target triples in config files
158+ /// are typically surrounded by:
159+ /// - Whitespace
160+ /// - Quotes (single or double)
161+ /// - Brackets
162+ /// - Commas
163+ /// - Newlines
164+ /// - Start/end of string
165+ fn contains_target_match ( content : & str , target : & str ) -> bool {
166+ // Find all occurrences of the target string
167+ let mut start = 0 ;
168+ while let Some ( pos) = content[ start..] . find ( target) {
169+ let absolute_pos = start + pos;
170+ let end_pos = absolute_pos + target. len ( ) ;
171+
172+ // Check character before (if any)
173+ let char_before = if absolute_pos > 0 {
174+ content[ ..absolute_pos] . chars ( ) . last ( )
175+ } else {
176+ None
177+ } ;
178+
179+ // Check character after (if any)
180+ let char_after = content[ end_pos..] . chars ( ) . next ( ) ;
181+
182+ // A target triple character is: alphanumeric, hyphen, or underscore
183+ // Note: Some targets have dots (e.g., "thumbv8m.main-none-eabi") but treating
184+ // dots as target chars breaks TOML table detection like [target.x86_64-linux-gnu].
185+ // Since dotted targets are rare (only thumbv8m.* variants) and must be quoted
186+ // in TOML anyway, we treat dots as boundaries. This works because:
187+ // - In arrays: "thumbv8m.main-none-eabi" - the target is quoted, boundaries are quotes
188+ // - In tables: [target."thumbv8m.main-none-eabi"] - must be quoted due to the dot
189+ let is_target_char = |c : char | c. is_alphanumeric ( ) || c == '-' || c == '_' ;
190+
191+ // Valid boundary: start of string, or non-target character
192+ let valid_before = char_before. is_none ( ) || !is_target_char ( char_before. unwrap ( ) ) ;
193+ // Valid boundary: end of string, or non-target character
194+ let valid_after = char_after. is_none ( ) || !is_target_char ( char_after. unwrap ( ) ) ;
195+
196+ if valid_before && valid_after {
197+ return true ;
198+ }
199+
200+ // Move past this match to find next occurrence
201+ start = absolute_pos + 1 ;
202+ }
203+
204+ false
205+ }
206+
154207/// Recursively find all .toml files in workspace
155208///
156209/// - Searches up to depth 3 (avoids excessive traversal)
@@ -621,4 +674,143 @@ jobs:
621674
622675 assert ! ( !has_github_workflows( temp. path( ) ) ) ;
623676 }
677+
678+ // ============================================================================
679+ // Word Boundary Matching Tests
680+ // ============================================================================
681+
682+ #[ test]
683+ fn test_contains_target_match_exact ( ) {
684+ assert ! ( contains_target_match( "thumbv7em-none-eabihf" , "thumbv7em-none-eabihf" ) ) ;
685+ }
686+
687+ #[ test]
688+ fn test_contains_target_match_quoted ( ) {
689+ assert ! ( contains_target_match( r#""thumbv7em-none-eabihf""# , "thumbv7em-none-eabihf" ) ) ;
690+ assert ! ( contains_target_match( r#"'thumbv7em-none-eabihf'"# , "thumbv7em-none-eabihf" ) ) ;
691+ }
692+
693+ #[ test]
694+ fn test_contains_target_match_in_array ( ) {
695+ let content = r#"targets = ["x86_64-unknown-linux-gnu", "aarch64-apple-darwin"]"# ;
696+ assert ! ( contains_target_match( content, "x86_64-unknown-linux-gnu" ) ) ;
697+ assert ! ( contains_target_match( content, "aarch64-apple-darwin" ) ) ;
698+ }
699+
700+ #[ test]
701+ fn test_contains_target_match_in_table_key ( ) {
702+ let content = r#"[target.thumbv7em-none-eabihf]
703+ linker = "arm-none-eabi-gcc""# ;
704+ assert ! ( contains_target_match( content, "thumbv7em-none-eabihf" ) ) ;
705+ }
706+
707+ #[ test]
708+ fn test_contains_target_match_rejects_substring ( ) {
709+ // This is the critical test - should NOT match shorter target when only longer exists
710+ let content = r#"targets = ["thumbv7em-none-eabihf"]"# ;
711+
712+ // Should match the actual target
713+ assert ! ( contains_target_match( content, "thumbv7em-none-eabihf" ) ) ;
714+
715+ // Should NOT match the shorter substring target
716+ assert ! (
717+ !contains_target_match( content, "thumbv7em-none-eabi" ) ,
718+ "Should not match 'thumbv7em-none-eabi' when file only contains 'thumbv7em-none-eabihf'"
719+ ) ;
720+ }
721+
722+ #[ test]
723+ fn test_contains_target_match_rejects_all_substring_cases ( ) {
724+ // Test several known substring cases from rustc target list
725+ let test_cases = [
726+ ( "aarch64-apple-ios-sim" , "aarch64-apple-ios" ) ,
727+ ( "arm-unknown-linux-gnueabihf" , "arm-unknown-linux-gnueabi" ) ,
728+ ( "x86_64-unknown-linux-gnux32" , "x86_64-unknown-linux-gnu" ) ,
729+ ( "wasm32-wasip1-threads" , "wasm32-wasip1" ) ,
730+ ( "x86_64-pc-windows-gnullvm" , "x86_64-pc-windows-gnu" ) ,
731+ ] ;
732+
733+ for ( full_target, substring_target) in test_cases {
734+ let content = format ! ( r#"targets = ["{}"]"# , full_target) ;
735+ assert ! (
736+ contains_target_match( & content, full_target) ,
737+ "Should match full target: {}" ,
738+ full_target
739+ ) ;
740+ assert ! (
741+ !contains_target_match( & content, substring_target) ,
742+ "Should NOT match substring '{}' when file only contains '{}'" ,
743+ substring_target,
744+ full_target
745+ ) ;
746+ }
747+ }
748+
749+ #[ test]
750+ fn test_contains_target_match_allows_both_when_both_present ( ) {
751+ // When both the short and long target are in the file, both should match
752+ let content = r#"targets = ["thumbv7em-none-eabi", "thumbv7em-none-eabihf"]"# ;
753+
754+ assert ! ( contains_target_match( content, "thumbv7em-none-eabi" ) ) ;
755+ assert ! ( contains_target_match( content, "thumbv7em-none-eabihf" ) ) ;
756+ }
757+
758+ #[ test]
759+ fn test_contains_target_match_yaml_format ( ) {
760+ let content = r#"
761+ matrix:
762+ target:
763+ - x86_64-unknown-linux-gnu
764+ - aarch64-apple-darwin
765+ "# ;
766+ assert ! ( contains_target_match( content, "x86_64-unknown-linux-gnu" ) ) ;
767+ assert ! ( contains_target_match( content, "aarch64-apple-darwin" ) ) ;
768+ // Should not false positive on similar targets
769+ assert ! ( !contains_target_match( content, "x86_64-unknown-linux-gnux32" ) ) ;
770+ }
771+
772+ #[ test]
773+ fn test_contains_target_match_with_dots ( ) {
774+ // Targets like thumbv8m.main-none-eabi have dots in them.
775+ // In TOML, these MUST be quoted because dots are path separators.
776+ // So we test the quoted form which is what users would actually write.
777+ let content = r#"[target."thumbv8m.main-none-eabihf"]"# ;
778+ assert ! ( contains_target_match( content, "thumbv8m.main-none-eabihf" ) ) ;
779+ assert ! ( !contains_target_match( content, "thumbv8m.main-none-eabi" ) ) ;
780+
781+ // Also test in array form
782+ let content2 = r#"targets = ["thumbv8m.main-none-eabihf"]"# ;
783+ assert ! ( contains_target_match( content2, "thumbv8m.main-none-eabihf" ) ) ;
784+ assert ! ( !contains_target_match( content2, "thumbv8m.main-none-eabi" ) ) ;
785+ }
786+
787+ #[ test]
788+ fn test_detect_targets_no_false_positives ( ) {
789+ let temp = TempDir :: new ( ) . unwrap ( ) ;
790+
791+ // Create a file with ONLY the longer target
792+ fs:: write (
793+ temp. path ( ) . join ( "rust-toolchain.toml" ) ,
794+ r#"
795+ [toolchain]
796+ channel = "stable"
797+ targets = ["thumbv7em-none-eabihf"]
798+ "# ,
799+ )
800+ . unwrap ( ) ;
801+
802+ let targets = detect_targets ( temp. path ( ) ) . unwrap ( ) ;
803+
804+ // Should find the actual target
805+ assert ! (
806+ targets. contains( & "thumbv7em-none-eabihf" . to_string( ) ) ,
807+ "Should detect thumbv7em-none-eabihf"
808+ ) ;
809+
810+ // Should NOT find the substring target
811+ assert ! (
812+ !targets. contains( & "thumbv7em-none-eabi" . to_string( ) ) ,
813+ "Should NOT detect thumbv7em-none-eabi (false positive)"
814+ ) ;
815+ }
624816}
0 commit comments