Skip to content

Commit 93fa35f

Browse files
committed
Improve sanitisation of paths and project names
1 parent 0785f39 commit 93fa35f

File tree

6 files changed

+208
-9
lines changed

6 files changed

+208
-9
lines changed

src/Spago/Command/Init.purs

Lines changed: 65 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ module Spago.Command.Init
66
, InitOptions
77
, defaultConfig
88
, defaultConfig'
9+
, folderToPackageName
910
, pursReplFile
1011
, run
1112
, srcMainTemplate
@@ -14,14 +15,15 @@ module Spago.Command.Init
1415

1516
import Spago.Prelude
1617

18+
import Data.Array (mapMaybe)
1719
import Data.Map as Map
1820
import Data.String as String
21+
import Data.String.Utils as StringUtils
1922
import Registry.PackageName as PackageName
2023
import Registry.Version as Version
2124
import Spago.Config (Dependencies(..), SetAddress(..), Config)
2225
import Spago.Config as Config
2326
import Spago.FS as FS
24-
import Spago.Log as Log
2527
import Spago.Path as Path
2628
import Spago.Registry (RegistryEnv)
2729
import Spago.Registry as Registry
@@ -111,14 +113,13 @@ run opts = do
111113
InitWorkspace { packageName: Nothing } -> String.take 150 $ Path.basename rootPath
112114
InitWorkspace { packageName: Just n } -> n
113115
InitSubpackage { packageName: n } -> n
114-
logDebug [ Path.quote rootPath, "\"" <> candidateName <> "\"" ]
115-
pname <- case PackageName.parse (PackageName.stripPureScriptPrefix candidateName) of
116-
Left err -> die
117-
[ toDoc "Could not figure out a name for the new package. Error:"
118-
, Log.break
119-
, Log.indent2 $ toDoc err
116+
pname <- case folderToPackageName candidateName of
117+
Nothing -> die
118+
[ "Could not derive a valid package name from directory " <> Path.quote rootPath <> "."
119+
, "Please use --name to specify a package name."
120120
]
121-
Right p -> pure p
121+
Just p -> pure p
122+
logDebug [ Path.quote rootPath, "\"" <> candidateName <> "\" -> \"" <> PackageName.print pname <> "\"" ]
122123
logDebug [ "Got packageName and setVersion:", PackageName.print pname, unsafeStringify opts.setVersion ]
123124
pure pname
124125

@@ -299,3 +300,59 @@ foundExistingDirectory dir = "Found existing directory " <> Path.quote dir <> ",
299300

300301
foundExistingFile :: LocalPath -> String
301302
foundExistingFile file = "Found existing file " <> Path.quote file <> ", not overwriting it"
303+
304+
-- SANITIZATION -----------------------------------------------------------------
305+
306+
-- | Convert a folder name to a valid package name.
307+
-- | We try to convert as much Unicode as possible to ASCII (through NFD normalisation),
308+
-- | and otherwise strip out and/or replace non-alpanumeric chars with dashes.
309+
-- | After all this work that is still not enough to guarantee a successful PackageName
310+
-- | parse, so this is still a Maybe.
311+
folderToPackageName :: String -> Maybe PackageName
312+
folderToPackageName input =
313+
input
314+
# String.toLower
315+
-- NFD normalization decomposes accented chars (é → e + combining accent)
316+
-- so the base ASCII letter is preserved when we filter non-ASCII later
317+
# StringUtils.normalize' StringUtils.NFD
318+
# String.toCodePointArray
319+
# mapMaybe sanitizeCodePoint
320+
# String.fromCodePointArray
321+
# collapseConsecutiveDashes
322+
# stripLeadingTrailingDashes
323+
# PackageName.stripPureScriptPrefix
324+
# PackageName.parse
325+
# hush
326+
where
327+
dash = String.codePointFromChar '-'
328+
329+
-- Transform each codepoint:
330+
-- - ASCII lowercase (a-z) and digits (0-9): keep as-is
331+
-- - Apostrophes and quotes: remove (shouldn't create word boundaries)
332+
-- - Other ASCII: convert to dash (word boundaries)
333+
-- - Non-ASCII (combining marks from NFD, etc.): remove
334+
sanitizeCodePoint cp
335+
| isAsciiLower cp || isAsciiDigit cp = Just cp
336+
| isRemovable cp = Nothing
337+
| isAscii cp = Just dash
338+
| otherwise = Nothing
339+
340+
isAsciiLower cp = cp >= String.codePointFromChar 'a' && cp <= String.codePointFromChar 'z'
341+
isAsciiDigit cp = cp >= String.codePointFromChar '0' && cp <= String.codePointFromChar '9'
342+
isAscii cp = cp <= String.codePointFromChar '\x7F'
343+
-- ASCII apostrophe and quote shouldn't create word boundaries (Tim's → tims, not tim-s)
344+
isRemovable cp = cp == String.codePointFromChar '\'' || cp == String.codePointFromChar '"'
345+
346+
-- Collapse consecutive dashes into one
347+
collapseConsecutiveDashes str =
348+
case String.indexOf (String.Pattern "--") str of
349+
Nothing -> str
350+
Just _ -> collapseConsecutiveDashes $ String.replaceAll (String.Pattern "--") (String.Replacement "-") str
351+
352+
-- Remove all leading and trailing dashes
353+
stripLeadingTrailingDashes str =
354+
case String.stripPrefix (String.Pattern "-") str of
355+
Just stripped -> stripLeadingTrailingDashes stripped
356+
Nothing -> case String.stripSuffix (String.Pattern "-") str of
357+
Just stripped -> stripLeadingTrailingDashes stripped
358+
Nothing -> str

src/Spago/Command/Run.purs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import Codec.JSON.DecodeError as CJ.DecodeError
1212
import Data.Array as Array
1313
import Data.Array.NonEmpty as NEA
1414
import Data.Map as Map
15+
import Data.String as String
16+
import JSURI (encodeURIComponent)
1517
import Node.FS.Perms as Perms
1618
import Registry.Version as Version
1719
import Spago.Cmd as Cmd
@@ -83,10 +85,17 @@ run = do
8385

8486
nodeArgs = [ Path.toRaw runJsPath ] <> opts.execArgs
8587

88+
-- Encode each path segment for use in file:// URL
89+
-- Splits on /, encodes each segment, rejoins with /
90+
encodeFileUrlPath str =
91+
String.split (String.Pattern "/") str
92+
# map (\seg -> fromMaybe seg (encodeURIComponent seg))
93+
# String.joinWith "/"
94+
8695
nodeContents =
8796
Array.fold
8897
[ "import { main } from 'file://"
89-
, Path.toRaw (withForwardSlashes absOutput)
98+
, encodeFileUrlPath $ Path.toRaw (withForwardSlashes absOutput)
9099
, "/"
91100
, opts.moduleName
92101
, "/"
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
✘ Could not derive a valid package name from directory "...".
2+
Please use --name to specify a package name.

test/Spago/Run.purs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,13 @@ module Test.Spago.Run where
22

33
import Test.Prelude
44

5+
import Data.String as String
56
import Spago.FS as FS
7+
import Spago.Path as Path
8+
import Spago.Paths as Paths
69
import Test.Spec (Spec)
710
import Test.Spec as Spec
11+
import Test.Spec.Assertions.String (shouldContain)
812

913
spec :: Spec Unit
1014
spec = Spec.around withTempDir do
@@ -44,3 +48,47 @@ spec = Spec.around withTempDir do
4448
spago [ "install", "node-process", "arrays" ] >>= shouldBeSuccess
4549
spago [ "build" ] >>= shouldBeSuccess
4650
spago [ "run", "bye" , "world" ] >>= shouldBeSuccessOutput (fixture "run-args-output2.txt")
51+
52+
Spec.it "works with special characters in path (apostrophe, spaces, brackets)" \{ spago, fixture, testCwd } -> do
53+
-- Test apostrophe - "Tim's Test" should become package "tims-test"
54+
let dir1 = testCwd </> "Tim's Test"
55+
FS.mkdirp dir1
56+
Paths.chdir dir1
57+
spago [ "init" ] >>= shouldBeSuccess
58+
config1 <- FS.readTextFile (dir1 </> "spago.yaml")
59+
config1 `shouldContain` "name: tims-test"
60+
spago [ "build" ] >>= shouldBeSuccess
61+
spago [ "run" ] >>= shouldBeSuccessOutput (fixture "run-output.txt")
62+
63+
-- Test spaces - "My Project Dir" should become "my-project-dir"
64+
let dir2 = testCwd </> "My Project Dir"
65+
FS.mkdirp dir2
66+
Paths.chdir dir2
67+
spago [ "init" ] >>= shouldBeSuccess
68+
config2 <- FS.readTextFile (dir2 </> "spago.yaml")
69+
config2 `shouldContain` "name: my-project-dir"
70+
spago [ "build" ] >>= shouldBeSuccess
71+
spago [ "run" ] >>= shouldBeSuccessOutput (fixture "run-output.txt")
72+
73+
-- Test multiple special characters - "Test #1 (dev)" should become "test-1-dev"
74+
let dir3 = testCwd </> "Test #1 (dev)"
75+
FS.mkdirp dir3
76+
Paths.chdir dir3
77+
spago [ "init" ] >>= shouldBeSuccess
78+
config3 <- FS.readTextFile (dir3 </> "spago.yaml")
79+
config3 `shouldContain` "name: test-1-dev"
80+
spago [ "build" ] >>= shouldBeSuccess
81+
spago [ "run" ] >>= shouldBeSuccessOutput (fixture "run-output.txt")
82+
83+
Spec.it "init fails gracefully when directory name has no valid characters" \{ spago, fixture, testCwd } -> do
84+
let dir = testCwd </> "..."
85+
FS.mkdirp dir
86+
Paths.chdir dir
87+
spago [ "init" ] >>= checkOutputs'
88+
{ stdoutFile: Nothing
89+
, stderrFile: Just (fixture "init-invalid-dirname.txt")
90+
, result: isLeft
91+
, sanitize:
92+
String.trim
93+
>>> String.replaceAll (String.Pattern $ Path.toRaw dir) (String.Replacement "...")
94+
}

test/Spago/Unit.purs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import Prelude
55
import Test.Spago.Unit.CheckInjectivity as CheckInjectivity
66
import Test.Spago.Unit.FindFlags as FindFlags
77
import Test.Spago.Unit.Git as Git
8+
import Test.Spago.Unit.Init as Init
89
import Test.Spago.Unit.NodeVersion as NodeVersion
910
import Test.Spago.Unit.Path as Path
1011
import Test.Spago.Unit.Printer as Printer
@@ -15,6 +16,7 @@ spec :: Spec Unit
1516
spec = Spec.describe "unit" do
1617
FindFlags.spec
1718
CheckInjectivity.spec
19+
Init.spec
1820
Printer.spec
1921
Git.spec
2022
Path.spec

test/Spago/Unit/Init.purs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
module Test.Spago.Unit.Init where
2+
3+
import Test.Prelude
4+
5+
import Registry.PackageName as PackageName
6+
import Spago.Command.Init (folderToPackageName)
7+
import Test.Spec (Spec)
8+
import Test.Spec as Spec
9+
import Test.Spec.Assertions (shouldSatisfy)
10+
11+
spec :: Spec Unit
12+
spec = Spec.describe "Init" do
13+
14+
Spec.describe "folderToPackageName" do
15+
16+
Spec.it "converts to lowercase" do
17+
folderToPackageName "MyProject" `shouldEqualPkg` "myproject"
18+
folderToPackageName "ALLCAPS" `shouldEqualPkg` "allcaps"
19+
20+
Spec.it "replaces spaces with dashes" do
21+
folderToPackageName "my project" `shouldEqualPkg` "my-project"
22+
folderToPackageName "My Project Dir" `shouldEqualPkg` "my-project-dir"
23+
24+
Spec.it "removes apostrophes (straight and curly)" do
25+
folderToPackageName "Tim's Test" `shouldEqualPkg` "tims-test"
26+
folderToPackageName "Tim's Test" `shouldEqualPkg` "tims-test"
27+
folderToPackageName "it's" `shouldEqualPkg` "its"
28+
29+
Spec.it "removes double quotes" do
30+
folderToPackageName "my\"project" `shouldEqualPkg` "myproject"
31+
folderToPackageName "\"test\"" `shouldEqualPkg` "test"
32+
33+
Spec.it "replaces special characters with dashes" do
34+
folderToPackageName "test#1" `shouldEqualPkg` "test-1"
35+
folderToPackageName "test(dev)" `shouldEqualPkg` "test-dev"
36+
folderToPackageName "test@home" `shouldEqualPkg` "test-home"
37+
folderToPackageName "test_underscore" `shouldEqualPkg` "test-underscore"
38+
39+
Spec.it "collapses consecutive dashes" do
40+
folderToPackageName "test--project" `shouldEqualPkg` "test-project"
41+
folderToPackageName "a b" `shouldEqualPkg` "a-b"
42+
folderToPackageName "Test #1 (dev)" `shouldEqualPkg` "test-1-dev"
43+
44+
Spec.it "strips leading dashes" do
45+
folderToPackageName "-test" `shouldEqualPkg` "test"
46+
folderToPackageName "---test" `shouldEqualPkg` "test"
47+
folderToPackageName "#test" `shouldEqualPkg` "test"
48+
49+
Spec.it "strips trailing dashes" do
50+
folderToPackageName "test-" `shouldEqualPkg` "test"
51+
folderToPackageName "test---" `shouldEqualPkg` "test"
52+
folderToPackageName "test#" `shouldEqualPkg` "test"
53+
54+
Spec.it "handles digits" do
55+
folderToPackageName "project123" `shouldEqualPkg` "project123"
56+
folderToPackageName "123project" `shouldEqualPkg` "123project"
57+
58+
Spec.it "returns Nothing for invalid inputs" do
59+
-- All special characters results in empty string
60+
shouldBeNothing $ folderToPackageName "..."
61+
shouldBeNothing $ folderToPackageName "###"
62+
shouldBeNothing $ folderToPackageName "'''"
63+
64+
Spec.it "converts accented characters to ASCII" do
65+
-- NFD normalization decomposes accents, keeping the base letter
66+
folderToPackageName "café" `shouldEqualPkg` "cafe"
67+
folderToPackageName "naïve" `shouldEqualPkg` "naive"
68+
folderToPackageName "über" `shouldEqualPkg` "uber"
69+
folderToPackageName "señor" `shouldEqualPkg` "senor"
70+
folderToPackageName "Ångström" `shouldEqualPkg` "angstrom"
71+
72+
Spec.it "strips purescript- prefix" do
73+
folderToPackageName "purescript-foo" `shouldEqualPkg` "foo"
74+
folderToPackageName "Purescript-Bar" `shouldEqualPkg` "bar"
75+
76+
where
77+
shouldEqualPkg actual expected =
78+
(PackageName.print <$> actual) `shouldEqual` Just expected
79+
80+
shouldBeNothing actual =
81+
(PackageName.print <$> actual) `shouldSatisfy` isNothing

0 commit comments

Comments
 (0)