Skip to content

Commit e85e9f6

Browse files
keep manifest decoding backward-compatible
Read stored manifests leniently again so historical SPDX spellings in the registry and registry-index still decode, but keep canonical-only decoding for new manifest input in publish and legacy import paths. Add direct regressions for the historical manifest/index case and document the boundary in the spec and public modules.
1 parent 609473e commit e85e9f6

8 files changed

Lines changed: 132 additions & 19 deletions

File tree

SPEC.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,7 @@ Alternately, this can be written without an id:
361361
**[Source](./lib/src/Registry/License.purs)**
362362
**[Spec](./types/v1/License.dhall)**
363363

364-
All packages in the registry must have a license that grants permission for redistribution of the source code. Concretely, the registry requires that all packages use an SPDX license and specify an [SPDX license identifier](https://spdx.dev/ids/). `AND` and `OR` conjunctions are allowed, and licenses can contain exceptions using the `WITH` preposition. The SPDX specification describes [how licenses can be combined and exceptions applied](https://spdx.dev/ids#how).
364+
All packages in the registry must have a license that grants permission for redistribution of the source code. Concretely, the registry requires that all packages use an SPDX license and specify an [SPDX license identifier](https://spdx.dev/ids/). `AND` and `OR` conjunctions are allowed, and licenses can contain exceptions using the `WITH` preposition. The SPDX specification describes [how licenses can be combined and exceptions applied](https://spdx.dev/ids#how). Newly submitted manifests must use current canonical SPDX identifiers, but registry readers should remain backward-compatible with historical stored manifests that still use deprecated SPDX spellings.
365365

366366
A `License` is represented as a string, which must be a valid SPDX identifier. For example:
367367

app/src/App/API.purs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -436,7 +436,7 @@ parseSourceManifest { packageDir, name, version, ref, location } = do
436436
Left error -> do
437437
Log.error $ "Manifest does not typecheck: " <> error
438438
Except.throw $ "Found a valid purs.json file in the package source, but it does not typecheck."
439-
Right _ -> case parseJson Manifest.codec string of
439+
Right _ -> case parseJson Manifest.canonicalCodec string of
440440
Left err -> do
441441
Log.error $ "Failed to parse manifest: " <> CJ.DecodeError.print err
442442
Except.throw $ "Found a purs.json file in the package source, but it could not be decoded."
@@ -1347,7 +1347,7 @@ validateLicense packageDir manifestLicense = do
13471347
detectedStrings # Array.mapMaybe \detectedLicense ->
13481348
hush do
13491349
canonicalized <- License.canonicalizeDetected detectedLicense
1350-
License.parse canonicalized
1350+
License.parseCanonical canonicalized
13511351

13521352
Log.debug $ "Detected licenses: " <> String.joinWith ", " detectedStrings
13531353

app/src/App/Legacy/Manifest.purs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ bowerfileToPursJson
7777
bowerfileToPursJson (Bowerfile { description, dependencies, license }) = do
7878
let
7979
-- Best effort: keep any licenses that parse cleanly and drop the rest.
80-
validLicenses = Array.mapMaybe (hush <<< License.parse) license
80+
validLicenses = Array.mapMaybe (hush <<< License.parseCanonical) license
8181

8282
parsedLicense <-
8383
case NonEmptyArray.fromArray validLicenses of
@@ -140,7 +140,7 @@ spagoDhallToPursJson
140140
spagoDhallToPursJson (SpagoDhallJson { license, dependencies, packages }) = do
141141
parsedLicense <- case license of
142142
Nothing -> Left "No license found in spago.dhall"
143-
Just lic -> case License.parse (NonEmptyString.toString lic) of
143+
Just lic -> case License.parseCanonical (NonEmptyString.toString lic) of
144144
Left _ -> Left $ "Invalid SPDX license in spago.dhall: " <> NonEmptyString.toString lic
145145
Right l -> Right l
146146

lib/src/License.purs

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,22 @@
66
-- | must install if you are using parsing code from this module. Please see the
77
-- | package.json file for exact versions.
88
-- |
9-
-- | `parse` is strict and intended for user-authored SPDX expressions in
10-
-- | registry manifests. `canonicalizeDetected` is intended for SPDX identifiers
11-
-- | emitted by external tooling like `licensee`, and only rewrites deprecated
12-
-- | identifiers when SPDX provides a deterministic canonical replacement.
9+
-- | `parse` accepts canonical SPDX expressions and historical deprecated SPDX
10+
-- | expressions when SPDX provides a deterministic canonical replacement. Use
11+
-- | `parseCanonical` when validating new user-authored manifest input.
12+
-- | `canonicalizeDetected` is intended for SPDX identifiers emitted by
13+
-- | external tooling like `licensee`, and only rewrites deprecated identifiers
14+
-- | when SPDX provides a deterministic canonical replacement.
1315
module Registry.License
1416
( License
1517
, SPDXConjunction(..)
18+
, canonicalCodec
1619
, canonicalizeDetected
1720
, codec
1821
, extractIds
1922
, joinWith
2023
, parse
24+
, parseCanonical
2125
, print
2226
) where
2327

@@ -46,12 +50,22 @@ newtype License = License LicenseTree
4650

4751
derive newtype instance Eq License
4852

49-
-- | A codec for encoding and decoding a `License` as JSON
53+
-- | A codec for encoding and decoding a `License` as JSON.
54+
-- | This decoder is backward-compatible with historical manifest data.
5055
codec :: CJ.Codec License
51-
codec = CJ.named "License" $ Codec.codec' decode encode
56+
codec = licenseCodec parse
57+
58+
-- | A codec for encoding and decoding a `License` as JSON.
59+
-- | This decoder accepts only canonical SPDX identifiers and is intended for
60+
-- | validating newly authored manifest input.
61+
canonicalCodec :: CJ.Codec License
62+
canonicalCodec = licenseCodec parseCanonical
63+
64+
licenseCodec :: (String -> Either String License) -> CJ.Codec License
65+
licenseCodec parseLicense = CJ.named "License" $ Codec.codec' decode encode
5266
where
5367
decode :: JSON -> Except CJ.DecodeError License
54-
decode = except <<< lmap CJ.DecodeError.basic <<< parse <=< Codec.decode CJ.string
68+
decode = except <<< lmap CJ.DecodeError.basic <<< parseLicense <=< Codec.decode CJ.string
5569

5670
encode :: License -> JSON
5771
encode = print >>> CJ.encode CJ.string
@@ -93,9 +107,19 @@ foreign import currentIds :: Array String
93107
foreign import deprecatedIds :: Array String
94108

95109
-- | Parse a string as a SPDX license identifier.
96-
-- | This is strict and accepts only current canonical SPDX identifiers.
110+
-- | This is backward-compatible with historical registry manifests and accepts
111+
-- | deprecated SPDX identifiers when the canonical replacement is
112+
-- | deterministic.
97113
parse :: String -> Either String License
98114
parse input = do
115+
parsedTree <- parseExpressionTree input
116+
canonicalTree <- canonicalizeParsedTree canonicalizeDetectedLeaf parsedTree
117+
pure $ License canonicalTree
118+
119+
-- | Parse a string as a canonical SPDX license identifier.
120+
-- | This is intended for validating newly authored manifest input.
121+
parseCanonical :: String -> Either String License
122+
parseCanonical input = do
99123
parsedTree <- parseExpressionTree input
100124
canonicalTree <- canonicalizeParsedTree canonicalizeStrictLeaf parsedTree
101125
pure $ License canonicalTree

lib/src/Manifest.purs

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
-- | https://github.com/purescript/registry-index
1414
module Registry.Manifest
1515
( Manifest(..)
16+
, canonicalCodec
1617
, codec
1718
) where
1819

@@ -70,12 +71,22 @@ instance Ord Manifest where
7071

7172
-- | A codec for encoding and decoding a `Manifest` as JSON. Represented as a
7273
-- | JSON object. The implementation uses explicitly ordered keys instead of
73-
-- | record sugar.
74+
-- | record sugar. This decoder remains backward-compatible with historical
75+
-- | manifests stored in the registry and manifest index.
7476
codec :: CJ.Codec Manifest
75-
codec = Profunctor.wrapIso Manifest $ CJ.named "Manifest" $ CJ.object
77+
codec = manifestCodec License.codec
78+
79+
-- | A codec for encoding and decoding a `Manifest` as JSON that requires
80+
-- | canonical SPDX identifiers in the license field. This is intended for
81+
-- | newly authored manifests submitted for publishing.
82+
canonicalCodec :: CJ.Codec Manifest
83+
canonicalCodec = manifestCodec License.canonicalCodec
84+
85+
manifestCodec :: CJ.Codec License -> CJ.Codec Manifest
86+
manifestCodec licenseCodec = Profunctor.wrapIso Manifest $ CJ.named "Manifest" $ CJ.object
7687
$ CJ.recordProp @"name" PackageName.codec
7788
$ CJ.recordProp @"version" Version.codec
78-
$ CJ.recordProp @"license" License.codec
89+
$ CJ.recordProp @"license" licenseCodec
7990
$ CJ.recordPropOptional @"description" (Internal.Codec.limitedString 300)
8091
$ CJ.recordProp @"location" Location.codec
8192
$ CJ.recordProp @"ref" CJ.string

lib/test/Registry/License.purs

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,24 @@ spec = do
3030
, Array.foldMap (append "\n - " <<< License.print) success
3131
]
3232

33-
Spec.it "Strict parsing rejects deprecated SPDX identifiers" do
33+
Spec.it "Parses and canonicalizes deterministic deprecated SPDX identifiers" do
34+
let
35+
cases =
36+
[ { input: "AGPL-3.0", output: "AGPL-3.0-only" }
37+
, { input: "AGPL-3.0+", output: "AGPL-3.0-or-later" }
38+
, { input: "GPL-2.0-with-classpath-exception", output: "GPL-2.0-only WITH Classpath-exception-2.0" }
39+
, { input: "GPL-2.0+", output: "GPL-2.0-or-later" }
40+
, { input: "GFDL-1.3+", output: "GFDL-1.3-or-later" }
41+
]
42+
43+
for_ cases \{ input, output } ->
44+
case License.parse input of
45+
Left err ->
46+
Assert.fail $ "Expected parse to succeed for " <> input <> ", but failed with: " <> err
47+
Right parsed ->
48+
Assert.shouldEqual output (License.print parsed)
49+
50+
Spec.it "Canonical parsing rejects deprecated SPDX identifiers" do
3451
let
3552
rejected =
3653
[ { input: "AGPL-3.0", expectedError: "AGPL-3.0-only" }
@@ -42,9 +59,9 @@ spec = do
4259
]
4360

4461
for_ rejected \{ input, expectedError } ->
45-
case License.parse input of
62+
case License.parseCanonical input of
4663
Right parsed ->
47-
Assert.fail $ "Expected strict parse to reject " <> input <> ", but parsed as " <> License.print parsed
64+
Assert.fail $ "Expected canonical parse to reject " <> input <> ", but parsed as " <> License.print parsed
4865
Left err ->
4966
unless (String.contains (Pattern expectedError) err) do
5067
Assert.fail $ "Expected parse error for " <> input <> " to mention " <> expectedError <> ", but got: " <> err

lib/test/Registry/Manifest.purs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,12 @@ module Test.Registry.Manifest (spec) where
22

33
import Prelude
44

5+
import Codec.JSON.DecodeError as CJ.DecodeError
6+
import Data.Codec.JSON as CJ
7+
import Data.Either (Either(..))
58
import Data.String as String
69
import Data.Traversable (for)
10+
import JSON as JSON
711
import Node.Encoding (Encoding(..))
812
import Node.FS.Aff as FS.Aff
913
import Node.Path as Path
@@ -21,3 +25,46 @@ spec = do
2125
rawManifest <- FS.Aff.readTextFile UTF8 $ Path.concat [ manifestFixturesPath, path ]
2226
pure { label: path, value: String.trim rawManifest }
2327
Assert.shouldRoundTrip "Manifest" Manifest.codec fixtures
28+
29+
Spec.it "Decodes historical manifests with deprecated SPDX identifiers" do
30+
let input = historicalManifest
31+
case JSON.parse input of
32+
Left err ->
33+
Assert.fail $ "Failed to parse test JSON: " <> err
34+
Right json ->
35+
case CJ.decode Manifest.codec json of
36+
Left err ->
37+
Assert.fail $ "Failed to decode historical manifest: " <> CJ.DecodeError.print err
38+
Right _ ->
39+
pure unit
40+
41+
Spec.it "Rejects deprecated SPDX identifiers in canonical manifest decoding" do
42+
let input = historicalManifest
43+
case JSON.parse input of
44+
Left err ->
45+
Assert.fail $ "Failed to parse test JSON: " <> err
46+
Right json ->
47+
case CJ.decode Manifest.canonicalCodec json of
48+
Left _ ->
49+
pure unit
50+
Right _ ->
51+
Assert.fail "Expected canonical manifest decoder to reject deprecated SPDX license"
52+
53+
historicalManifest :: String
54+
historicalManifest =
55+
String.trim
56+
"""
57+
{
58+
"name": "jarilo",
59+
"version": "1.0.1",
60+
"license": "AGPL-3.0",
61+
"location": {
62+
"githubOwner": "bklaric",
63+
"githubRepo": "purescript-jarilo"
64+
},
65+
"ref": "v1.0.1",
66+
"dependencies": {
67+
"prelude": ">=6.0.0 <7.0.0"
68+
}
69+
}
70+
"""

lib/test/Registry/ManifestIndex.purs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ spec = do
4545
let parsedContext = ManifestIndex.parseEntry contextEntry
4646
contextEntry `Assert.shouldEqualRight` map (ManifestIndex.printEntry <<< NonEmptySet.fromFoldable1) parsedContext
4747

48+
Spec.it "Parses historical manifest-index entries with deprecated SPDX identifiers" do
49+
let parsedHistorical = ManifestIndex.parseEntry historicalAgplEntry
50+
historicalCanonicalEntry `Assert.shouldEqualRight` map (ManifestIndex.printEntry <<< NonEmptySet.fromFoldable1) parsedHistorical
51+
4852
Spec.it "Produces correct entry file paths" do
4953
let
5054
entries =
@@ -156,6 +160,16 @@ contextEntry =
156160
{"name":"context","version":"0.0.3","license":"MIT","location":{"githubOwner":"Fresheyeball","githubRepo":"purescript-owner"},"ref":"v0.0.3","dependencies":{}}
157161
"""
158162

163+
historicalAgplEntry :: String
164+
historicalAgplEntry =
165+
"""{"name":"jarilo","version":"1.0.1","license":"AGPL-3.0","location":{"githubOwner":"bklaric","githubRepo":"purescript-jarilo"},"ref":"v1.0.1","dependencies":{"prelude":">=6.0.0 <7.0.0"}}
166+
"""
167+
168+
historicalCanonicalEntry :: String
169+
historicalCanonicalEntry =
170+
"""{"name":"jarilo","version":"1.0.1","license":"AGPL-3.0-only","location":{"githubOwner":"bklaric","githubRepo":"purescript-jarilo"},"ref":"v1.0.1","dependencies":{"prelude":">=6.0.0 <7.0.0"}}
171+
"""
172+
159173
testIndex
160174
:: forall m
161175
. MonadThrow Error m

0 commit comments

Comments
 (0)