Skip to content

Commit 8d23a8f

Browse files
Support syntax-only SPDX expression parsing (#25)
* Support syntax-only SPDX expression parsing * Validate SPDX reference identifiers * Tighten SPDX identifier syntax validation
1 parent d392f44 commit 8d23a8f

3 files changed

Lines changed: 309 additions & 58 deletions

File tree

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,17 @@ fmt.Println(expr.String()) // "MIT OR (GPL-2.0-only AND Apache-2.0)"
4646
// ParseStrict requires valid SPDX IDs (no fuzzy normalization)
4747
expr, err := spdx.ParseStrict("MIT OR Apache-2.0") // succeeds
4848
expr, err := spdx.ParseStrict("Apache 2 OR MIT") // fails
49+
50+
// ParseSyntax validates expression grammar without requiring identifiers
51+
// to exist in the SPDX list bundled by this module
52+
expr, err := spdx.ParseSyntax("Future-License-1.0 OR MIT") // succeeds
4953
```
5054

55+
`ParseSyntax` is useful when the caller validates identifiers against another
56+
pinned data source. Known identifiers are returned in their canonical form,
57+
while well-formed unknown license and exception identifiers are preserved.
58+
`ParseStrict` continues to require identifiers from the bundled SPDX list.
59+
5160
### Rewrite expression identifiers
5261

5362
Parse an expression once, then replace its identifiers while keeping its

parse.go

Lines changed: 138 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -255,13 +255,17 @@ const (
255255

256256
// parser parses SPDX expressions.
257257
type parser struct {
258-
lexer *lexer
259-
current token
260-
depth int
258+
lexer *lexer
259+
current token
260+
depth int
261+
allowUnknownIdentifiers bool
261262
}
262263

263-
func newParser(input string) (*parser, error) {
264-
p := &parser{lexer: newLexer(input)}
264+
func newParser(input string, allowUnknownIdentifiers bool) (*parser, error) {
265+
p := &parser{
266+
lexer: newLexer(input),
267+
allowUnknownIdentifiers: allowUnknownIdentifiers,
268+
}
265269
tok, err := p.lexer.next()
266270
if err != nil {
267271
return nil, err
@@ -306,21 +310,12 @@ func Parse(expression string) (Expression, error) {
306310
return nil, err
307311
}
308312

309-
p, err := newParser(normalized)
310-
if err != nil {
311-
return nil, err
312-
}
313-
314-
expr, err := p.parseExpression()
313+
p, err := newParser(normalized, false)
315314
if err != nil {
316315
return nil, err
317316
}
318317

319-
if p.current.typ != tokenEOF {
320-
return nil, fmt.Errorf("%w: %s", ErrUnexpectedToken, p.current.value)
321-
}
322-
323-
return expr, nil
318+
return p.parse()
324319
}
325320

326321
// ParseStrict parses an SPDX expression requiring strict SPDX identifiers.
@@ -333,6 +328,30 @@ func Parse(expression string) (Expression, error) {
333328
// ParseStrict("MIT OR Apache-2.0") // succeeds
334329
// ParseStrict("mit OR apache 2") // fails - "apache 2" is not a valid SPDX ID
335330
func ParseStrict(expression string) (Expression, error) {
331+
return parseWithoutNormalization(expression, false)
332+
}
333+
334+
// ParseSyntax parses an SPDX expression without requiring bare license and
335+
// exception identifiers to exist in the bundled SPDX identifier list. It
336+
// validates identifier syntax, operators, modifiers, and grouping. Known
337+
// identifiers are returned in their canonical form; unknown identifiers are
338+
// preserved as written.
339+
//
340+
// Use ParseStrict when identifiers must also be present in the bundled SPDX
341+
// list.
342+
//
343+
// Example:
344+
//
345+
// ParseSyntax("Future-License-1.0 OR MIT") // succeeds
346+
// ParseStrict("Future-License-1.0 OR MIT") // fails
347+
func ParseSyntax(expression string) (Expression, error) {
348+
return parseWithoutNormalization(expression, true)
349+
}
350+
351+
func parseWithoutNormalization(
352+
expression string,
353+
allowUnknownIdentifiers bool,
354+
) (Expression, error) {
336355
expression = strings.TrimSpace(expression)
337356
if expression == "" {
338357
return nil, ErrEmptyExpression
@@ -341,11 +360,15 @@ func ParseStrict(expression string) (Expression, error) {
341360
return nil, ErrExpressionTooLarge
342361
}
343362

344-
p, err := newParser(expression)
363+
p, err := newParser(expression, allowUnknownIdentifiers)
345364
if err != nil {
346365
return nil, err
347366
}
348367

368+
return p.parse()
369+
}
370+
371+
func (p *parser) parse() (Expression, error) {
349372
expr, err := p.parseExpression()
350373
if err != nil {
351374
return nil, err
@@ -426,9 +449,9 @@ func (p *parser) parseWith() (Expression, error) {
426449
return nil, fmt.Errorf("%w: expected exception after WITH", ErrMissingOperand)
427450
}
428451

429-
exception := lookupException(p.current.value)
430-
if exception == "" {
431-
return nil, fmt.Errorf("%w: %s", ErrInvalidException, p.current.value)
452+
exception, err := p.resolveExceptionIdentifier(p.current.value)
453+
if err != nil {
454+
return nil, err
432455
}
433456

434457
license.Exception = exception
@@ -471,59 +494,116 @@ func (p *parser) parseAtom() (Expression, error) {
471494
return expr, nil
472495

473496
case tokenLicense:
474-
value := p.current.value
475-
upper := strings.ToUpper(value)
476-
477-
// Handle special values
478-
if upper == "NONE" || upper == "NOASSERTION" {
479-
if err := p.advance(); err != nil {
480-
return nil, err
481-
}
482-
return &SpecialValue{Value: upper}, nil
483-
}
497+
return p.parseLicenseAtom()
484498

485-
// Look up the canonical license ID
486-
id := lookupLicense(value)
487-
if id == "" {
488-
return nil, fmt.Errorf("%w: %s", ErrInvalidLicenseID, value)
489-
}
490-
491-
license := &License{ID: id}
499+
case tokenLicenseRef:
500+
return p.parseLicenseReference(false)
492501

493-
if err := p.advance(); err != nil {
494-
return nil, err
495-
}
502+
case tokenDocumentRef:
503+
return p.parseLicenseReference(true)
496504

497-
// Check for +
498-
if p.current.typ == tokenPlus {
499-
license.Plus = true
500-
if err := p.advance(); err != nil {
501-
return nil, err
502-
}
503-
}
505+
case tokenEOF:
506+
return nil, ErrMissingOperand
504507

505-
return license, nil
508+
default:
509+
return nil, fmt.Errorf("%w: %s", ErrUnexpectedToken, p.current.value)
510+
}
511+
}
506512

507-
case tokenLicenseRef:
508-
ref := parseLicenseRef(p.current.value)
513+
func (p *parser) parseLicenseAtom() (Expression, error) {
514+
value := p.current.value
515+
upper := strings.ToUpper(value)
516+
if upper == "NONE" || upper == "NOASSERTION" {
509517
if err := p.advance(); err != nil {
510518
return nil, err
511519
}
512-
return ref, nil
520+
return &SpecialValue{Value: upper}, nil
521+
}
513522

514-
case tokenDocumentRef:
515-
ref := parseDocumentRef(p.current.value)
523+
id, err := p.resolveLicenseIdentifier(value)
524+
if err != nil {
525+
return nil, err
526+
}
527+
license := &License{ID: id}
528+
if err := p.advance(); err != nil {
529+
return nil, err
530+
}
531+
if p.current.typ == tokenPlus {
532+
license.Plus = true
516533
if err := p.advance(); err != nil {
517534
return nil, err
518535
}
519-
return ref, nil
536+
}
537+
return license, nil
538+
}
520539

521-
case tokenEOF:
522-
return nil, ErrMissingOperand
540+
func (p *parser) resolveLicenseIdentifier(identifier string) (string, error) {
541+
if id := lookupLicense(identifier); id != "" {
542+
return id, nil
543+
}
544+
if p.allowUnknownIdentifiers && validIdentifier(identifier) {
545+
return identifier, nil
546+
}
547+
return "", fmt.Errorf("%w: %s", ErrInvalidLicenseID, identifier)
548+
}
523549

524-
default:
525-
return nil, fmt.Errorf("%w: %s", ErrUnexpectedToken, p.current.value)
550+
func (p *parser) resolveExceptionIdentifier(identifier string) (string, error) {
551+
if exception := lookupException(identifier); exception != "" {
552+
return exception, nil
526553
}
554+
if p.allowUnknownIdentifiers && validIdentifier(identifier) {
555+
return identifier, nil
556+
}
557+
return "", fmt.Errorf("%w: %s", ErrInvalidException, identifier)
558+
}
559+
560+
func (p *parser) parseLicenseReference(document bool) (Expression, error) {
561+
value := p.current.value
562+
var reference *LicenseRef
563+
if document {
564+
reference = parseDocumentRef(value)
565+
} else {
566+
reference = parseLicenseRef(value)
567+
}
568+
if !validLicenseReference(reference, document) {
569+
return nil, fmt.Errorf("%w: %s", ErrInvalidLicenseID, value)
570+
}
571+
if err := p.advance(); err != nil {
572+
return nil, err
573+
}
574+
return reference, nil
575+
}
576+
577+
func validLicenseReference(reference *LicenseRef, document bool) bool {
578+
if reference == nil || !validIdentifier(reference.LicenseRef) {
579+
return false
580+
}
581+
if document {
582+
return validIdentifier(reference.DocumentRef)
583+
}
584+
return reference.DocumentRef == ""
585+
}
586+
587+
func validIdentifier(identifier string) bool {
588+
if identifier == "" || !isIdentifierAlphanumeric(rune(identifier[0])) ||
589+
!isIdentifierAlphanumeric(rune(identifier[len(identifier)-1])) {
590+
return false
591+
}
592+
for _, character := range identifier {
593+
switch {
594+
case isIdentifierAlphanumeric(character):
595+
case character == '-', character == '.':
596+
default:
597+
return false
598+
}
599+
}
600+
return true
601+
}
602+
603+
func isIdentifierAlphanumeric(character rune) bool {
604+
return character >= 'a' && character <= 'z' ||
605+
character >= 'A' && character <= 'Z' ||
606+
character >= '0' && character <= '9'
527607
}
528608

529609
// parseLicenseRef parses "LicenseRef-xxx" into a LicenseRef.

0 commit comments

Comments
 (0)