Skip to content

Commit 7bf832d

Browse files
authored
Merge branch 'master' into feat/purge-build-cache
2 parents 446e2af + 4b70e02 commit 7bf832d

25 files changed

+384
-105
lines changed

arduino/cores/fqbn.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,26 @@ func (fqbn *FQBN) String() string {
7676
return res
7777
}
7878

79+
// Match check if the target FQBN corresponds to the receiver one.
80+
// The core parts are checked for exact equality while board options are loosely
81+
// matched: the set of boards options of the target must be fully contained within
82+
// the one of the receiver and their values must be equal.
83+
func (fqbn *FQBN) Match(target *FQBN) bool {
84+
if fqbn.StringWithoutConfig() != target.StringWithoutConfig() {
85+
return false
86+
}
87+
88+
searchedProperties := target.Configs.Clone()
89+
actualConfigs := fqbn.Configs.AsMap()
90+
for neededKey, neededValue := range searchedProperties.AsMap() {
91+
targetValue, hasKey := actualConfigs[neededKey]
92+
if !hasKey || targetValue != neededValue {
93+
return false
94+
}
95+
}
96+
return true
97+
}
98+
7999
// StringWithoutConfig returns the FQBN without the Config part
80100
func (fqbn *FQBN) StringWithoutConfig() string {
81101
return fqbn.Package + ":" + fqbn.PlatformArch + ":" + fqbn.BoardID

arduino/cores/fqbn_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,3 +121,37 @@ func TestFQBN(t *testing.T) {
121121
"properties.Map{\n \"cpu\": \"atmega\",\n \"speed\": \"1000\",\n \"extra\": \"core=arduino\",\n}",
122122
f.Configs.Dump())
123123
}
124+
125+
func TestMatch(t *testing.T) {
126+
expectedMatches := [][]string{
127+
{"arduino:avr:uno", "arduino:avr:uno"},
128+
{"arduino:avr:uno", "arduino:avr:uno:opt1=1,opt2=2"},
129+
{"arduino:avr:uno:opt1=1", "arduino:avr:uno:opt1=1,opt2=2"},
130+
{"arduino:avr:uno:opt1=1,opt2=2", "arduino:avr:uno:opt1=1,opt2=2"},
131+
{"arduino:avr:uno:opt3=3,opt1=1,opt2=2", "arduino:avr:uno:opt2=2,opt3=3,opt1=1,opt4=4"},
132+
}
133+
134+
for _, pair := range expectedMatches {
135+
a, err := ParseFQBN(pair[0])
136+
require.NoError(t, err)
137+
b, err := ParseFQBN(pair[1])
138+
require.NoError(t, err)
139+
require.True(t, b.Match(a))
140+
}
141+
142+
expectedMismatches := [][]string{
143+
{"arduino:avr:uno", "arduino:avr:due"},
144+
{"arduino:avr:uno", "arduino:avr:due:opt1=1,opt2=2"},
145+
{"arduino:avr:uno:opt1=1", "arduino:avr:uno"},
146+
{"arduino:avr:uno:opt1=1,opt2=", "arduino:avr:uno:opt1=1,opt2=3"},
147+
{"arduino:avr:uno:opt1=1,opt2=2", "arduino:avr:uno:opt2=2"},
148+
}
149+
150+
for _, pair := range expectedMismatches {
151+
a, err := ParseFQBN(pair[0])
152+
require.NoError(t, err)
153+
b, err := ParseFQBN(pair[1])
154+
require.NoError(t, err)
155+
require.False(t, b.Match(a))
156+
}
157+
}

commands/board/list.go

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,15 @@ func List(req *rpc.BoardListRequest) (r []*rpc.DetectedPort, discoveryStartError
205205
}
206206
defer release()
207207

208+
var fqbnFilter *cores.FQBN
209+
if f := req.GetFqbn(); f != "" {
210+
var err error
211+
fqbnFilter, err = cores.ParseFQBN(f)
212+
if err != nil {
213+
return nil, nil, &arduino.InvalidFQBNError{Cause: err}
214+
}
215+
}
216+
208217
dm := pme.DiscoveryManager()
209218
discoveryStartErrors = dm.Start()
210219
time.Sleep(time.Duration(req.GetTimeout()) * time.Millisecond)
@@ -222,11 +231,27 @@ func List(req *rpc.BoardListRequest) (r []*rpc.DetectedPort, discoveryStartError
222231
Port: port.ToRPC(),
223232
MatchingBoards: boards,
224233
}
225-
retVal = append(retVal, b)
234+
235+
if fqbnFilter == nil || hasMatchingBoard(b, fqbnFilter) {
236+
retVal = append(retVal, b)
237+
}
226238
}
227239
return retVal, discoveryStartErrors, nil
228240
}
229241

242+
func hasMatchingBoard(b *rpc.DetectedPort, fqbnFilter *cores.FQBN) bool {
243+
for _, detectedBoard := range b.MatchingBoards {
244+
detectedFqbn, err := cores.ParseFQBN(detectedBoard.Fqbn)
245+
if err != nil {
246+
continue
247+
}
248+
if detectedFqbn.Match(fqbnFilter) {
249+
return true
250+
}
251+
}
252+
return false
253+
}
254+
230255
// Watch returns a channel that receives boards connection and disconnection events.
231256
// It also returns a callback function that must be used to stop and dispose the watch.
232257
func Watch(req *rpc.BoardListWatchRequest) (<-chan *rpc.BoardListWatchResponse, func(), error) {

commands/sketch/new.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package sketch
1818
import (
1919
"context"
2020
"errors"
21+
"regexp"
2122

2223
"github.com/arduino/arduino-cli/arduino"
2324
"github.com/arduino/arduino-cli/arduino/globals"
@@ -34,6 +35,10 @@ void loop() {
3435
}
3536
`)
3637

38+
// sketchNameMaxLength could be part of the regex, but it's intentionally left out for clearer error reporting
39+
var sketchNameMaxLength = 63
40+
var sketchNameValidationRegex = regexp.MustCompile(`^[0-9a-zA-Z][0-9a-zA-Z_\.-]*$`)
41+
3742
// NewSketch creates a new sketch via gRPC
3843
func NewSketch(ctx context.Context, req *rpc.NewSketchRequest) (*rpc.NewSketchResponse, error) {
3944
var sketchesDir string
@@ -42,6 +47,11 @@ func NewSketch(ctx context.Context, req *rpc.NewSketchRequest) (*rpc.NewSketchRe
4247
} else {
4348
sketchesDir = configuration.Settings.GetString("directories.User")
4449
}
50+
51+
if err := validateSketchName(req.SketchName); err != nil {
52+
return nil, err
53+
}
54+
4555
sketchDirPath := paths.New(sketchesDir).Join(req.SketchName)
4656
if err := sketchDirPath.MkdirAll(); err != nil {
4757
return nil, &arduino.CantCreateSketchError{Cause: err}
@@ -59,3 +69,20 @@ func NewSketch(ctx context.Context, req *rpc.NewSketchRequest) (*rpc.NewSketchRe
5969

6070
return &rpc.NewSketchResponse{MainFile: sketchMainFilePath.String()}, nil
6171
}
72+
73+
func validateSketchName(name string) error {
74+
if name == "" {
75+
return &arduino.CantCreateSketchError{Cause: errors.New(tr("sketch name cannot be empty"))}
76+
}
77+
if len(name) > sketchNameMaxLength {
78+
return &arduino.CantCreateSketchError{Cause: errors.New(tr("sketch name too long (%d characters). Maximum allowed length is %d",
79+
len(name),
80+
sketchNameMaxLength))}
81+
}
82+
if !sketchNameValidationRegex.MatchString(name) {
83+
return &arduino.CantCreateSketchError{Cause: errors.New(tr("invalid sketch name \"%s\". Required pattern %s",
84+
name,
85+
sketchNameValidationRegex.String()))}
86+
}
87+
return nil
88+
}

commands/sketch/new_test.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package sketch
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/arduino/arduino-cli/rpc/cc/arduino/cli/commands/v1"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func Test_SketchNameWrongPattern(t *testing.T) {
12+
invalidNames := []string{
13+
"&",
14+
"",
15+
".hello",
16+
"_hello",
17+
"-hello",
18+
"hello*",
19+
"||||||||||||||",
20+
",`hack[}attempt{];",
21+
}
22+
for _, name := range invalidNames {
23+
_, err := NewSketch(context.Background(), &commands.NewSketchRequest{
24+
SketchName: name,
25+
SketchDir: t.TempDir(),
26+
})
27+
require.NotNil(t, err)
28+
29+
require.Error(t, err, `Can't create sketch: invalid sketch name "%s". Required pattern %s`,
30+
name,
31+
sketchNameValidationRegex)
32+
}
33+
}
34+
35+
func Test_SketchNameEmpty(t *testing.T) {
36+
emptyName := ""
37+
_, err := NewSketch(context.Background(), &commands.NewSketchRequest{
38+
SketchName: emptyName,
39+
SketchDir: t.TempDir(),
40+
})
41+
require.NotNil(t, err)
42+
43+
require.Error(t, err, `Can't create sketch: sketch name cannot be empty`)
44+
}
45+
46+
func Test_SketchNameTooLong(t *testing.T) {
47+
tooLongName := make([]byte, sketchNameMaxLength+1)
48+
for i := range tooLongName {
49+
tooLongName[i] = 'a'
50+
}
51+
_, err := NewSketch(context.Background(), &commands.NewSketchRequest{
52+
SketchName: string(tooLongName),
53+
SketchDir: t.TempDir(),
54+
})
55+
require.NotNil(t, err)
56+
57+
require.Error(t, err, `Can't create sketch: sketch name too long (%d characters). Maximum allowed length is %d`,
58+
len(tooLongName),
59+
sketchNameMaxLength)
60+
}
61+
62+
func Test_SketchNameOk(t *testing.T) {
63+
lengthLimitName := make([]byte, sketchNameMaxLength)
64+
for i := range lengthLimitName {
65+
lengthLimitName[i] = 'a'
66+
}
67+
validNames := []string{
68+
"h",
69+
"h.ello",
70+
"h..ello-world",
71+
"h..ello-world.",
72+
"hello_world__",
73+
string(lengthLimitName),
74+
}
75+
for _, name := range validNames {
76+
_, err := NewSketch(context.Background(), &commands.NewSketchRequest{
77+
SketchName: name,
78+
SketchDir: t.TempDir(),
79+
})
80+
require.Nil(t, err)
81+
}
82+
}

docs/UPGRADING.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ Here you can find a list of migration guides to handle breaking changes between
44

55
## 0.30.0
66

7+
### Sketch name validation
8+
9+
The sketch name submitted via the `sketch new` command of the CLI or the gRPC command
10+
`cc.arduino.cli.commands.v1.NewSketch` are now validated. The applied rules follow the
11+
[sketch specifications](https://arduino.github.io/arduino-cli/dev/sketch-specification).
12+
13+
Existing sketch names violating the new constraint need to be updated.
14+
715
### `daemon` CLI command's `--ip` flag removal
816

917
The `daemon` CLI command no longer allows to set a custom ip for the gRPC communication. Currently there is not enough

internal/cli/board/list.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,12 @@
1616
package board
1717

1818
import (
19+
"errors"
1920
"fmt"
2021
"os"
2122
"sort"
2223

24+
"github.com/arduino/arduino-cli/arduino"
2325
"github.com/arduino/arduino-cli/arduino/cores"
2426
"github.com/arduino/arduino-cli/commands/board"
2527
"github.com/arduino/arduino-cli/internal/cli/arguments"
@@ -47,6 +49,7 @@ func initListCommand() *cobra.Command {
4749
}
4850

4951
timeoutArg.AddToCommand(listCommand)
52+
fqbn.AddToCommand(listCommand)
5053
listCommand.Flags().BoolVarP(&watch, "watch", "w", false, tr("Command keeps running and prints list of connected boards whenever there is a change."))
5154

5255
return listCommand
@@ -63,14 +66,19 @@ func runListCommand(cmd *cobra.Command, args []string) {
6366
return
6467
}
6568

66-
ports, discvoeryErrors, err := board.List(&rpc.BoardListRequest{
69+
ports, discoveryErrors, err := board.List(&rpc.BoardListRequest{
6770
Instance: inst,
6871
Timeout: timeoutArg.Get().Milliseconds(),
72+
Fqbn: fqbn.String(),
6973
})
74+
var invalidFQBNErr *arduino.InvalidFQBNError
75+
if errors.As(err, &invalidFQBNErr) {
76+
feedback.Fatal(tr(err.Error()), feedback.ErrBadArgument)
77+
}
7078
if err != nil {
7179
feedback.Warning(tr("Error detecting boards: %v", err))
7280
}
73-
for _, err := range discvoeryErrors {
81+
for _, err := range discoveryErrors {
7482
feedback.Warning(tr("Error starting discovery: %v", err))
7583
}
7684
feedback.PrintResult(result{ports})

internal/cli/sketch/new.go

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,16 +49,33 @@ func initNewCommand() *cobra.Command {
4949
func runNewCommand(args []string, overwrite bool) {
5050
logrus.Info("Executing `arduino-cli sketch new`")
5151
// Trim to avoid issues if user creates a sketch adding the .ino extesion to the name
52-
sketchName := args[0]
53-
trimmedSketchName := strings.TrimSuffix(sketchName, globals.MainFileValidExtension)
54-
sketchDirPath, err := paths.New(trimmedSketchName).Abs()
55-
if err != nil {
56-
feedback.Fatal(tr("Error creating sketch: %v", err), feedback.ErrGeneric)
52+
inputSketchName := args[0]
53+
trimmedSketchName := strings.TrimSuffix(inputSketchName, globals.MainFileValidExtension)
54+
55+
var sketchDir string
56+
var sketchName string
57+
var sketchDirPath *paths.Path
58+
var err error
59+
60+
if trimmedSketchName == "" {
61+
// `paths.New` returns nil with an empty string so `paths.Abs` panics.
62+
// if the name is empty we rely on the "new" command to fail nicely later
63+
// with the same logic in grpc and cli flows
64+
sketchDir = ""
65+
sketchName = ""
66+
} else {
67+
sketchDirPath, err = paths.New(trimmedSketchName).Abs()
68+
if err != nil {
69+
feedback.Fatal(tr("Error creating sketch: %v", err), feedback.ErrGeneric)
70+
}
71+
sketchDir = sketchDirPath.Parent().String()
72+
sketchName = sketchDirPath.Base()
5773
}
74+
5875
_, err = sk.NewSketch(context.Background(), &rpc.NewSketchRequest{
5976
Instance: nil,
60-
SketchName: sketchDirPath.Base(),
61-
SketchDir: sketchDirPath.Parent().String(),
77+
SketchName: sketchName,
78+
SketchDir: sketchDir,
6279
Overwrite: overwrite,
6380
})
6481
if err != nil {

internal/integrationtest/board/board_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,39 @@ func TestBoardList(t *testing.T) {
9191
MustBeEmpty()
9292
}
9393

94+
func TestBoardListWithFqbnFilter(t *testing.T) {
95+
if os.Getenv("CI") != "" {
96+
t.Skip("VMs have no serial ports")
97+
}
98+
99+
env, cli := integrationtest.CreateArduinoCLIWithEnvironment(t)
100+
defer env.CleanUp()
101+
102+
_, _, err := cli.Run("core", "update-index")
103+
require.NoError(t, err)
104+
stdout, _, err := cli.Run("board", "list", "-b", "foo:bar:baz", "--format", "json")
105+
require.NoError(t, err)
106+
// this is a bit of a passpartout test, it actually filters the "bluetooth boards" locally
107+
// but it would succeed even if the filtering wasn't working properly
108+
// TODO: find a way to simulate connected boards or create a unit test which
109+
// mocks or initializes multiple components
110+
requirejson.Parse(t, stdout).
111+
MustBeEmpty()
112+
}
113+
114+
func TestBoardListWithFqbnFilterInvalid(t *testing.T) {
115+
if os.Getenv("CI") != "" {
116+
t.Skip("VMs have no serial ports")
117+
}
118+
119+
env, cli := integrationtest.CreateArduinoCLIWithEnvironment(t)
120+
defer env.CleanUp()
121+
122+
_, stderr, err := cli.Run("board", "list", "-b", "yadayada", "--format", "json")
123+
require.Error(t, err)
124+
requirejson.Query(t, stderr, ".error", `"Invalid FQBN: not an FQBN: yadayada"`)
125+
}
126+
94127
func TestBoardListWithInvalidDiscovery(t *testing.T) {
95128
env, cli := integrationtest.CreateArduinoCLIWithEnvironment(t)
96129
defer env.CleanUp()

0 commit comments

Comments
 (0)