• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

lightningnetwork / lnd / 13813778123

12 Mar 2025 02:24PM UTC coverage: 68.65% (+0.003%) from 68.647%
13813778123

Pull #9546

github

web-flow
Merge dca2eb21c into 6531d4505
Pull Request #9546: macaroons: ip range constraint

18 of 50 new or added lines in 3 files covered. (36.0%)

68 existing lines in 18 files now uncovered.

130428 of 189991 relevant lines covered (68.65%)

23571.33 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/cmd/commands/cmd_macaroon.go
1
package commands
2

3
import (
4
        "bytes"
5
        "encoding/hex"
6
        "fmt"
7
        "net"
8
        "os"
9
        "strconv"
10
        "strings"
11
        "unicode"
12

13
        "github.com/lightningnetwork/lnd/fn/v2"
14
        "github.com/lightningnetwork/lnd/lncfg"
15
        "github.com/lightningnetwork/lnd/lnrpc"
16
        "github.com/lightningnetwork/lnd/macaroons"
17
        "github.com/urfave/cli"
18
        "google.golang.org/protobuf/proto"
19
        "gopkg.in/macaroon-bakery.v2/bakery"
20
        "gopkg.in/macaroon.v2"
21
)
22

23
var (
24
        macTimeoutFlag = cli.Uint64Flag{
25
                Name: "timeout",
26
                Usage: "the number of seconds the macaroon will be " +
27
                        "valid before it times out",
28
        }
29
        macIPAddressFlag = cli.StringFlag{
30
                Name:  "ip_address",
31
                Usage: "the IP address the macaroon will be bound to",
32
        }
33
        macIPRangeFlag = cli.StringFlag{
34
                Name:  "ip_range",
35
                Usage: "the IP range the macaroon will be bound to",
36
        }
37
        macCustomCaveatNameFlag = cli.StringFlag{
38
                Name:  "custom_caveat_name",
39
                Usage: "the name of the custom caveat to add",
40
        }
41
        macCustomCaveatConditionFlag = cli.StringFlag{
42
                Name: "custom_caveat_condition",
43
                Usage: "the condition of the custom caveat to add, can be " +
44
                        "empty if custom caveat doesn't need a value",
45
        }
46
        bakeFromRootKeyFlag = cli.StringFlag{
47
                Name: "root_key",
48
                Usage: "if the root key is known, it can be passed directly " +
49
                        "as a hex encoded string, turning the command into " +
50
                        "an offline operation",
51
        }
52
)
53

54
var bakeMacaroonCommand = cli.Command{
55
        Name:     "bakemacaroon",
56
        Category: "Macaroons",
57
        Usage: "Bakes a new macaroon with the provided list of permissions " +
58
                "and restrictions.",
59
        ArgsUsage: "[--save_to=] [--timeout=] [--ip_address=] " +
60
                "[--custom_caveat_name= [--custom_caveat_condition=]] " +
61
                "[--root_key_id=] [--allow_external_permissions] " +
62
                "[--root_key=] permissions...",
63
        Description: `
64
        Bake a new macaroon that grants the provided permissions and
65
        optionally adds restrictions (timeout, IP address) to it.
66

67
        The new macaroon can either be shown on command line in hex serialized
68
        format or it can be saved directly to a file using the --save_to
69
        argument.
70

71
        A permission is a tuple of an entity and an action, separated by a
72
        colon. Multiple operations can be added as arguments, for example:
73

74
        lncli bakemacaroon info:read invoices:write foo:bar
75

76
        For even more fine-grained permission control, it is also possible to
77
        specify single RPC method URIs that are allowed to be accessed by a
78
        macaroon. This can be achieved by specifying "uri:<methodURI>" pairs,
79
        for example:
80

81
        lncli bakemacaroon uri:/lnrpc.Lightning/GetInfo uri:/verrpc.Versioner/GetVersion
82

83
        The macaroon created by this command would only be allowed to use the
84
        "lncli getinfo" and "lncli version" commands.
85

86
        To get a list of all available URIs and permissions, use the
87
        "lncli listpermissions" command.
88

89
        If the root key is known (for example because "lncli create" was used
90
        with a custom --mac_root_key value), it can be passed directly as a
91
        hex encoded string using the --root_key flag. This turns the command
92
        into an offline operation and the macaroon will be created without
93
        calling into the server's RPC endpoint.
94
        `,
95
        Flags: []cli.Flag{
96
                cli.StringFlag{
97
                        Name: "save_to",
98
                        Usage: "save the created macaroon to this file " +
99
                                "using the default binary format",
100
                },
101
                macTimeoutFlag,
102
                macIPAddressFlag,
103
                macCustomCaveatNameFlag,
104
                macCustomCaveatConditionFlag,
105
                cli.Uint64Flag{
106
                        Name: "root_key_id",
107
                        Usage: "the numerical root key ID used to create the " +
108
                                "macaroon",
109
                },
110
                cli.BoolFlag{
111
                        Name: "allow_external_permissions",
112
                        Usage: "whether permissions lnd is not familiar with " +
113
                                "are allowed",
114
                },
115
                bakeFromRootKeyFlag,
116
        },
117
        Action: actionDecorator(bakeMacaroon),
118
}
119

120
func bakeMacaroon(ctx *cli.Context) error {
×
121
        ctxc := getContext()
×
122

×
123
        // Show command help if no arguments.
×
124
        if ctx.NArg() == 0 {
×
125
                return cli.ShowCommandHelp(ctx, "bakemacaroon")
×
126
        }
×
127
        args := ctx.Args()
×
128

×
129
        var (
×
130
                savePath          string
×
131
                rootKeyID         uint64
×
132
                parsedPermissions []*lnrpc.MacaroonPermission
×
133
                err               error
×
134
        )
×
135

×
136
        if ctx.String("save_to") != "" {
×
137
                savePath = lncfg.CleanAndExpandPath(ctx.String("save_to"))
×
138
        }
×
139

140
        if ctx.IsSet("root_key_id") {
×
141
                rootKeyID = ctx.Uint64("root_key_id")
×
142
        }
×
143

144
        // A command line argument can't be an empty string. So we'll check each
145
        // entry if it's a valid entity:action tuple. The content itself is
146
        // validated server side. We just make sure we can parse it correctly.
147
        for _, permission := range args {
×
148
                tuple := strings.Split(permission, ":")
×
149
                if len(tuple) != 2 {
×
150
                        return fmt.Errorf("unable to parse "+
×
151
                                "permission tuple: %s", permission)
×
152
                }
×
153
                entity, action := tuple[0], tuple[1]
×
154
                if entity == "" {
×
155
                        return fmt.Errorf("invalid permission [%s]. entity "+
×
156
                                "cannot be empty", permission)
×
157
                }
×
158
                if action == "" {
×
159
                        return fmt.Errorf("invalid permission [%s]. action "+
×
160
                                "cannot be empty", permission)
×
161
                }
×
162

163
                // No we can assume that we have a formally valid entity:action
164
                // tuple. The rest of the validation happens server side.
165
                parsedPermissions = append(
×
166
                        parsedPermissions, &lnrpc.MacaroonPermission{
×
167
                                Entity: entity,
×
168
                                Action: action,
×
169
                        },
×
170
                )
×
171
        }
172

173
        var rawMacaroon *macaroon.Macaroon
×
174
        switch {
×
175
        case ctx.IsSet(bakeFromRootKeyFlag.Name):
×
176
                macRootKey, err := hex.DecodeString(
×
177
                        ctx.String(bakeFromRootKeyFlag.Name),
×
178
                )
×
179
                if err != nil {
×
180
                        return fmt.Errorf("unable to parse macaroon root key: "+
×
181
                                "%w", err)
×
182
                }
×
183

184
                ops := fn.Map(
×
185
                        parsedPermissions,
×
186
                        func(p *lnrpc.MacaroonPermission) bakery.Op {
×
187
                                return bakery.Op{
×
188
                                        Entity: p.Entity,
×
189
                                        Action: p.Action,
×
190
                                }
×
191
                        },
×
192
                )
193

194
                rawMacaroon, err = macaroons.BakeFromRootKey(macRootKey, ops)
×
195
                if err != nil {
×
196
                        return fmt.Errorf("unable to bake macaroon: %w", err)
×
197
                }
×
198

199
        default:
×
200
                client, cleanUp := getClient(ctx)
×
201
                defer cleanUp()
×
202

×
203
                // Now we have gathered all the input we need and can do the
×
204
                // actual RPC call.
×
205
                req := &lnrpc.BakeMacaroonRequest{
×
206
                        Permissions: parsedPermissions,
×
207
                        RootKeyId:   rootKeyID,
×
208
                        AllowExternalPermissions: ctx.Bool(
×
209
                                "allow_external_permissions",
×
210
                        ),
×
211
                }
×
212
                resp, err := client.BakeMacaroon(ctxc, req)
×
213
                if err != nil {
×
214
                        return err
×
215
                }
×
216

217
                // Now we should have gotten a valid macaroon. Unmarshal it so
218
                // we can add first-party caveats (if necessary) to it.
219
                macBytes, err := hex.DecodeString(resp.Macaroon)
×
220
                if err != nil {
×
221
                        return err
×
222
                }
×
223
                rawMacaroon = &macaroon.Macaroon{}
×
224
                if err = rawMacaroon.UnmarshalBinary(macBytes); err != nil {
×
225
                        return err
×
226
                }
×
227
        }
228

229
        // Now apply the desired constraints to the macaroon. This will always
230
        // create a new macaroon object, even if no constraints are added.
231
        constrainedMac, err := applyMacaroonConstraints(ctx, rawMacaroon)
×
232
        if err != nil {
×
233
                return err
×
234
        }
×
235
        macBytes, err := constrainedMac.MarshalBinary()
×
236
        if err != nil {
×
237
                return err
×
238
        }
×
239

240
        // Now we can output the result. We either write it binary serialized to
241
        // a file or write to the standard output using hex encoding.
242
        switch {
×
243
        case savePath != "":
×
244
                err = os.WriteFile(savePath, macBytes, 0644)
×
245
                if err != nil {
×
246
                        return err
×
247
                }
×
248
                fmt.Printf("Macaroon saved to %s\n", savePath)
×
249

250
        default:
×
251
                fmt.Printf("%s\n", hex.EncodeToString(macBytes))
×
252
        }
253

254
        return nil
×
255
}
256

257
var listMacaroonIDsCommand = cli.Command{
258
        Name:     "listmacaroonids",
259
        Category: "Macaroons",
260
        Usage:    "List all macaroons root key IDs in use.",
261
        Action:   actionDecorator(listMacaroonIDs),
262
}
263

264
func listMacaroonIDs(ctx *cli.Context) error {
×
265
        ctxc := getContext()
×
266
        client, cleanUp := getClient(ctx)
×
267
        defer cleanUp()
×
268

×
269
        req := &lnrpc.ListMacaroonIDsRequest{}
×
270
        resp, err := client.ListMacaroonIDs(ctxc, req)
×
271
        if err != nil {
×
272
                return err
×
273
        }
×
274

275
        printRespJSON(resp)
×
276
        return nil
×
277
}
278

279
var deleteMacaroonIDCommand = cli.Command{
280
        Name:      "deletemacaroonid",
281
        Category:  "Macaroons",
282
        Usage:     "Delete a specific macaroon ID.",
283
        ArgsUsage: "root_key_id",
284
        Description: `
285
        Remove a macaroon ID using the specified root key ID. For example:
286

287
        lncli deletemacaroonid 1
288

289
        WARNING
290
        When the ID is deleted, all macaroons created from that root key will
291
        be invalidated.
292

293
        Note that the default root key ID 0 cannot be deleted.
294
        `,
295
        Action: actionDecorator(deleteMacaroonID),
296
}
297

298
func deleteMacaroonID(ctx *cli.Context) error {
×
299
        ctxc := getContext()
×
300
        client, cleanUp := getClient(ctx)
×
301
        defer cleanUp()
×
302

×
303
        // Validate args length. Only one argument is allowed.
×
304
        if ctx.NArg() != 1 {
×
305
                return cli.ShowCommandHelp(ctx, "deletemacaroonid")
×
306
        }
×
307

308
        rootKeyIDString := ctx.Args().First()
×
309

×
310
        // Convert string into uint64.
×
311
        rootKeyID, err := strconv.ParseUint(rootKeyIDString, 10, 64)
×
312
        if err != nil {
×
313
                return fmt.Errorf("root key ID must be a positive integer")
×
314
        }
×
315

316
        // Check that the value is not equal to DefaultRootKeyID. Note that the
317
        // server also validates the root key ID when removing it. However, we check
318
        // it here too so that we can give users a nice warning.
319
        if bytes.Equal([]byte(rootKeyIDString), macaroons.DefaultRootKeyID) {
×
320
                return fmt.Errorf("deleting the default root key ID 0 is not allowed")
×
321
        }
×
322

323
        // Make the actual RPC call.
324
        req := &lnrpc.DeleteMacaroonIDRequest{
×
325
                RootKeyId: rootKeyID,
×
326
        }
×
327
        resp, err := client.DeleteMacaroonID(ctxc, req)
×
328
        if err != nil {
×
329
                return err
×
330
        }
×
331

332
        printRespJSON(resp)
×
333
        return nil
×
334
}
335

336
var listPermissionsCommand = cli.Command{
337
        Name:     "listpermissions",
338
        Category: "Macaroons",
339
        Usage: "Lists all RPC method URIs and the macaroon permissions they " +
340
                "require to be invoked.",
341
        Action: actionDecorator(listPermissions),
342
}
343

344
func listPermissions(ctx *cli.Context) error {
×
345
        ctxc := getContext()
×
346
        client, cleanUp := getClient(ctx)
×
347
        defer cleanUp()
×
348

×
349
        request := &lnrpc.ListPermissionsRequest{}
×
350
        response, err := client.ListPermissions(ctxc, request)
×
351
        if err != nil {
×
352
                return err
×
353
        }
×
354

355
        printRespJSON(response)
×
356

×
357
        return nil
×
358
}
359

360
type macaroonContent struct {
361
        Version     uint16   `json:"version"`
362
        Location    string   `json:"location"`
363
        RootKeyID   string   `json:"root_key_id"`
364
        Permissions []string `json:"permissions"`
365
        Caveats     []string `json:"caveats"`
366
}
367

368
var printMacaroonCommand = cli.Command{
369
        Name:      "printmacaroon",
370
        Category:  "Macaroons",
371
        Usage:     "Print the content of a macaroon in a human readable format.",
372
        ArgsUsage: "[macaroon_content_hex]",
373
        Description: `
374
        Decode a macaroon and show its content in a more human readable format.
375
        The macaroon can either be passed as a hex encoded positional parameter
376
        or loaded from a file.
377
        `,
378
        Flags: []cli.Flag{
379
                cli.StringFlag{
380
                        Name: "macaroon_file",
381
                        Usage: "load the macaroon from a file instead of the " +
382
                                "command line directly",
383
                },
384
        },
385
        Action: actionDecorator(printMacaroon),
386
}
387

388
func printMacaroon(ctx *cli.Context) error {
×
389
        // Show command help if no arguments or flags are set.
×
390
        if ctx.NArg() == 0 && ctx.NumFlags() == 0 {
×
391
                return cli.ShowCommandHelp(ctx, "printmacaroon")
×
392
        }
×
393

394
        var (
×
395
                macBytes []byte
×
396
                err      error
×
397
                args     = ctx.Args()
×
398
        )
×
399
        switch {
×
400
        case ctx.IsSet("macaroon_file"):
×
401
                macPath := lncfg.CleanAndExpandPath(ctx.String("macaroon_file"))
×
402

×
403
                // Load the specified macaroon file.
×
404
                macBytes, err = os.ReadFile(macPath)
×
405
                if err != nil {
×
406
                        return fmt.Errorf("unable to read macaroon path %v: %v",
×
407
                                macPath, err)
×
408
                }
×
409

410
        case args.Present():
×
411
                macBytes, err = hex.DecodeString(args.First())
×
412
                if err != nil {
×
413
                        return fmt.Errorf("unable to hex decode macaroon: %w",
×
414
                                err)
×
415
                }
×
416

417
        default:
×
418
                return fmt.Errorf("macaroon parameter missing")
×
419
        }
420

421
        // Decode the macaroon and its protobuf encoded internal identifier.
422
        mac := &macaroon.Macaroon{}
×
423
        if err = mac.UnmarshalBinary(macBytes); err != nil {
×
424
                return fmt.Errorf("unable to decode macaroon: %w", err)
×
425
        }
×
426
        rawID := mac.Id()
×
427
        if rawID[0] != byte(bakery.LatestVersion) {
×
428
                return fmt.Errorf("invalid macaroon version: %x", rawID)
×
429
        }
×
430
        decodedID := &lnrpc.MacaroonId{}
×
431
        idProto := rawID[1:]
×
432
        err = proto.Unmarshal(idProto, decodedID)
×
433
        if err != nil {
×
434
                return fmt.Errorf("unable to decode macaroon version: %w", err)
×
435
        }
×
436

437
        // Prepare everything to be printed in a more human-readable format.
438
        content := &macaroonContent{
×
439
                Version:     uint16(mac.Version()),
×
440
                Location:    mac.Location(),
×
441
                RootKeyID:   string(decodedID.StorageId),
×
442
                Permissions: nil,
×
443
                Caveats:     nil,
×
444
        }
×
445

×
446
        for _, caveat := range mac.Caveats() {
×
447
                content.Caveats = append(content.Caveats, string(caveat.Id))
×
448
        }
×
449
        for _, op := range decodedID.Ops {
×
450
                for _, action := range op.Actions {
×
451
                        permission := fmt.Sprintf("%s:%s", op.Entity, action)
×
452
                        content.Permissions = append(
×
453
                                content.Permissions, permission,
×
454
                        )
×
455
                }
×
456
        }
457

458
        printJSON(content)
×
459

×
460
        return nil
×
461
}
462

463
var constrainMacaroonCommand = cli.Command{
464
        Name:     "constrainmacaroon",
465
        Category: "Macaroons",
466
        Usage:    "Adds one or more restriction(s) to an existing macaroon",
467
        ArgsUsage: "[--timeout=] [--ip_address=] [--custom_caveat_name= " +
468
                "[--custom_caveat_condition=]] input-macaroon-file " +
469
                "constrained-macaroon-file",
470
        Description: `
471
        Add one or more first-party caveat(s) (a.k.a. constraints/restrictions)
472
        to an existing macaroon.
473
        `,
474
        Flags: []cli.Flag{
475
                macTimeoutFlag,
476
                macIPAddressFlag,
477
                macCustomCaveatNameFlag,
478
                macCustomCaveatConditionFlag,
479
        },
480
        Action: actionDecorator(constrainMacaroon),
481
}
482

483
func constrainMacaroon(ctx *cli.Context) error {
×
484
        // Show command help if not enough arguments.
×
485
        if ctx.NArg() != 2 {
×
486
                return cli.ShowCommandHelp(ctx, "constrainmacaroon")
×
487
        }
×
488
        args := ctx.Args()
×
489

×
490
        sourceMacFile := lncfg.CleanAndExpandPath(args.First())
×
491
        args = args.Tail()
×
492

×
493
        sourceMacBytes, err := os.ReadFile(sourceMacFile)
×
494
        if err != nil {
×
495
                return fmt.Errorf("error trying to read source macaroon file "+
×
496
                        "%s: %v", sourceMacFile, err)
×
497
        }
×
498

499
        destMacFile := lncfg.CleanAndExpandPath(args.First())
×
500

×
501
        // Now we should have gotten a valid macaroon. Unmarshal it so we can
×
502
        // add first-party caveats (if necessary) to it.
×
503
        sourceMac := &macaroon.Macaroon{}
×
504
        if err = sourceMac.UnmarshalBinary(sourceMacBytes); err != nil {
×
505
                return fmt.Errorf("error unmarshalling source macaroon file "+
×
506
                        "%s: %v", sourceMacFile, err)
×
507
        }
×
508

509
        // Now apply the desired constraints to the macaroon. This will always
510
        // create a new macaroon object, even if no constraints are added.
511
        constrainedMac, err := applyMacaroonConstraints(ctx, sourceMac)
×
512
        if err != nil {
×
513
                return err
×
514
        }
×
515

516
        destMacBytes, err := constrainedMac.MarshalBinary()
×
517
        if err != nil {
×
518
                return fmt.Errorf("error marshaling destination macaroon "+
×
519
                        "file: %v", err)
×
520
        }
×
521

522
        // Now we can output the result.
523
        err = os.WriteFile(destMacFile, destMacBytes, 0644)
×
524
        if err != nil {
×
525
                return fmt.Errorf("error writing destination macaroon file "+
×
526
                        "%s: %v", destMacFile, err)
×
527
        }
×
528
        fmt.Printf("Macaroon saved to %s\n", destMacFile)
×
529

×
530
        return nil
×
531
}
532

533
// applyMacaroonConstraints parses and applies all currently supported macaroon
534
// condition flags from the command line to the given macaroon and returns a new
535
// macaroon instance.
536
func applyMacaroonConstraints(ctx *cli.Context,
537
        mac *macaroon.Macaroon) (*macaroon.Macaroon, error) {
×
538

×
539
        macConstraints := make([]macaroons.Constraint, 0)
×
540

×
541
        if ctx.IsSet(macTimeoutFlag.Name) {
×
542
                timeout := ctx.Int64(macTimeoutFlag.Name)
×
543
                if timeout <= 0 {
×
544
                        return nil, fmt.Errorf("timeout must be greater than 0")
×
545
                }
×
546
                macConstraints = append(
×
547
                        macConstraints, macaroons.TimeoutConstraint(timeout),
×
548
                )
×
549
        }
550

551
        if ctx.IsSet(macIPAddressFlag.Name) {
×
552
                ipAddress := net.ParseIP(ctx.String(macIPAddressFlag.Name))
×
553
                if ipAddress == nil {
×
554
                        return nil, fmt.Errorf("unable to parse ip_address: %s",
×
555
                                ctx.String("ip_address"))
×
556
                }
×
557

558
                macConstraints = append(
×
559
                        macConstraints,
×
560
                        macaroons.IPLockConstraint(ipAddress.String()),
×
561
                )
×
562
        }
563

NEW
564
        if ctx.IsSet(macIPRangeFlag.Name) {
×
NEW
565
                _, net, err := net.ParseCIDR(ctx.String(macIPRangeFlag.Name))
×
NEW
566
                if err != nil {
×
NEW
567
                        return nil, fmt.Errorf("unable to parse ip_range "+
×
NEW
568
                                "%s: %w", ctx.String("ip_range"), err)
×
NEW
569
                }
×
570

NEW
571
                macConstraints = append(
×
NEW
572
                        macConstraints,
×
NEW
573
                        macaroons.IPLockConstraint(net.String()),
×
NEW
574
                )
×
575
        }
576

577
        if ctx.IsSet(macCustomCaveatNameFlag.Name) {
×
578
                customCaveatName := ctx.String(macCustomCaveatNameFlag.Name)
×
579
                if containsWhiteSpace(customCaveatName) {
×
580
                        return nil, fmt.Errorf("unexpected white space found " +
×
581
                                "in custom caveat name")
×
582
                }
×
583
                if customCaveatName == "" {
×
584
                        return nil, fmt.Errorf("invalid custom caveat name")
×
585
                }
×
586

587
                var customCaveatCond string
×
588
                if ctx.IsSet(macCustomCaveatConditionFlag.Name) {
×
589
                        customCaveatCond = ctx.String(
×
590
                                macCustomCaveatConditionFlag.Name,
×
591
                        )
×
592
                        if containsWhiteSpace(customCaveatCond) {
×
593
                                return nil, fmt.Errorf("unexpected white " +
×
594
                                        "space found in custom caveat " +
×
595
                                        "condition")
×
596
                        }
×
597
                        if customCaveatCond == "" {
×
598
                                return nil, fmt.Errorf("invalid custom " +
×
599
                                        "caveat condition")
×
600
                        }
×
601
                }
602

603
                // The custom caveat condition is optional, it could just be a
604
                // marker tag in the macaroon with just a name. The interceptor
605
                // itself doesn't care about the value anyway.
606
                macConstraints = append(
×
607
                        macConstraints, macaroons.CustomConstraint(
×
608
                                customCaveatName, customCaveatCond,
×
609
                        ),
×
610
                )
×
611
        }
612

613
        constrainedMac, err := macaroons.AddConstraints(mac, macConstraints...)
×
614
        if err != nil {
×
615
                return nil, fmt.Errorf("error adding constraints: %w", err)
×
616
        }
×
617

618
        return constrainedMac, nil
×
619
}
620

621
// containsWhiteSpace returns true if the given string contains any character
622
// that is considered to be a white space or non-printable character such as
623
// space, tabulator, newline, carriage return and some more exotic ones.
624
func containsWhiteSpace(str string) bool {
×
625
        return strings.IndexFunc(str, unicode.IsSpace) >= 0
×
626
}
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc