Skip to content

Commit 1d4c9aa

Browse files
authored
Improve sanitisation of paths and project names (#1374)
1 parent 3d63a26 commit 1d4c9aa

8 files changed

Lines changed: 286 additions & 13 deletions

File tree

‎src/Spago/Command/Init.purs‎

Lines changed: 82 additions & 12 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,8 +15,10 @@ 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)
@@ -106,19 +109,30 @@ run opts = do
106109
getPackageName :: Spago (InitEnv a) PackageName
107110
getPackageName = do
108111
{ rootPath } <- ask
112+
-- When the user explicitly provides a name, validate it directly and show the actual error.
113+
-- When deriving from directory name, use folderToPackageName which sanitizes and gives a generic error.
109114
let
110-
candidateName = case opts.mode of
111-
InitWorkspace { packageName: Nothing } -> String.take 150 $ Path.basename rootPath
112-
InitWorkspace { packageName: Just n } -> n
113-
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
120-
]
121-
Right p -> pure p
115+
explicitName = case opts.mode of
116+
InitWorkspace { packageName: Just n } -> Just n
117+
InitSubpackage { packageName: n } -> Just n
118+
InitWorkspace { packageName: Nothing } -> Nothing
119+
pname <- case explicitName of
120+
Just n -> case PackageName.parse (PackageName.stripPureScriptPrefix n) of
121+
Left err -> die
122+
[ toDoc "Could not figure out a name for the new package. Error:"
123+
, Log.break
124+
, Log.indent2 $ toDoc err
125+
]
126+
Right p -> pure p
127+
Nothing -> do
128+
let candidateName = String.take 150 $ Path.basename rootPath
129+
case folderToPackageName candidateName of
130+
Nothing -> die
131+
[ "Could not derive a valid package name from directory " <> Path.quote rootPath <> "."
132+
, "Please use --name to specify a package name."
133+
]
134+
Just p -> pure p
135+
logDebug [ Path.quote rootPath, PackageName.print pname ]
122136
logDebug [ "Got packageName and setVersion:", PackageName.print pname, unsafeStringify opts.setVersion ]
123137
pure pname
124138

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

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

‎src/Spago/Command/Run.purs‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
module Spago.Command.Run
22
( getNode
33
, run
4+
, encodeFileUrlPath
45
, RunEnv
56
, Node
67
, RunOptions
@@ -12,6 +13,9 @@ import Codec.JSON.DecodeError as CJ.DecodeError
1213
import Data.Array as Array
1314
import Data.Array.NonEmpty as NEA
1415
import Data.Map as Map
16+
import Data.String as String
17+
import Data.String.CodeUnits as SCU
18+
import JSURI (encodeURIComponent)
1519
import Node.FS.Perms as Perms
1620
import Registry.Version as Version
1721
import Spago.Cmd as Cmd
@@ -46,6 +50,26 @@ type RunOptions =
4650

4751
type Node = { cmd :: GlobalPath, version :: Version }
4852

53+
-- | Encode a file path for use in a file:// URL.
54+
-- | Encodes special characters (spaces, apostrophes, etc.) but preserves
55+
-- | Windows drive letters (e.g., "C:") since encoding the colon breaks URLs.
56+
encodeFileUrlPath :: String -> String
57+
encodeFileUrlPath str =
58+
String.split (String.Pattern "/") str
59+
# map encodeSegment
60+
# String.joinWith "/"
61+
where
62+
encodeSegment seg
63+
| isWindowsDrive seg = seg
64+
| otherwise = fromMaybe seg (encodeURIComponent seg)
65+
66+
-- Windows drive letter: single ASCII letter followed by colon (e.g., "C:", "D:")
67+
isWindowsDrive seg = case SCU.toCharArray seg of
68+
[ letter, ':' ] -> isAsciiLetter letter
69+
_ -> false
70+
71+
isAsciiLetter c = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
72+
4973
nodeVersion :: forall a. Spago (LogEnv a) Version
5074
nodeVersion =
5175
Cmd.exec (Path.global "node") [ "--version" ] Cmd.defaultExecOptions { pipeStdout = false, pipeStderr = false } >>= case _ of
@@ -86,7 +110,7 @@ run = do
86110
nodeContents =
87111
Array.fold
88112
[ "import { main } from 'file://"
89-
, Path.toRaw (withForwardSlashes absOutput)
113+
, encodeFileUrlPath $ Path.toRaw (withForwardSlashes absOutput)
90114
, "/"
91115
, opts.moduleName
92116
, "/"
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/Publish.purs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,9 @@ spec = Spec.around withTempDir do
8585
spago [ "build" ] >>= shouldBeSuccess
8686
doTheGitThing
8787
spago [ "fetch" ] >>= shouldBeSuccess
88+
-- Refresh the registry cache timestamp because Windows CI is slow enough
89+
-- that it can go stale (>15min) between earlier tests and this one
90+
spago [ "registry", "package-sets" ] >>= shouldBeSuccess
8891
spago [ "publish", "-p", "root", "--offline" ] >>= shouldBeFailureErr (fixture "publish/1307-publish-dependencies/expected-stderr.txt")
8992

9093
Spec.it "#1110 installs versions of packages that are returned by the registry solver, but not present in cache" \{ spago, fixture, testCwd } -> do

‎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: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,21 @@ 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
12+
import Test.Spago.Unit.Run as Run
1113
import Test.Spec (Spec)
1214
import Test.Spec as Spec
1315

1416
spec :: Spec Unit
1517
spec = Spec.describe "unit" do
1618
FindFlags.spec
1719
CheckInjectivity.spec
20+
Init.spec
1821
Printer.spec
1922
Git.spec
2023
Path.spec
2124
NodeVersion.spec
25+
Run.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

‎test/Spago/Unit/Run.purs‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
module Test.Spago.Unit.Run where
2+
3+
import Test.Prelude
4+
5+
import Spago.Command.Run (encodeFileUrlPath)
6+
import Test.Spec (Spec)
7+
import Test.Spec as Spec
8+
9+
spec :: Spec Unit
10+
spec = Spec.describe "Run" do
11+
12+
Spec.describe "encodeFileUrlPath" do
13+
14+
Spec.it "encodes spaces" do
15+
encodeFileUrlPath "/path/with spaces/file" `shouldEqual` "/path/with%20spaces/file"
16+
17+
Spec.it "encodes apostrophes" do
18+
encodeFileUrlPath "/Volumes/Tim's Docs/project" `shouldEqual` "/Volumes/Tim%27s%20Docs/project"
19+
20+
Spec.it "encodes hash symbols" do
21+
encodeFileUrlPath "/path/test#1/file" `shouldEqual` "/path/test%231/file"
22+
23+
Spec.it "encodes brackets" do
24+
encodeFileUrlPath "/path/test[dev]/file" `shouldEqual` "/path/test%5Bdev%5D/file"
25+
26+
Spec.it "preserves Windows drive letters" do
27+
encodeFileUrlPath "C:/Users/test" `shouldEqual` "C:/Users/test"
28+
encodeFileUrlPath "D:/a/spago/output" `shouldEqual` "D:/a/spago/output"
29+
30+
Spec.it "preserves Windows drive letters with special chars in path" do
31+
encodeFileUrlPath "C:/Users/Tim's Folder/project" `shouldEqual` "C:/Users/Tim%27s%20Folder/project"
32+
33+
Spec.it "handles lowercase drive letters" do
34+
encodeFileUrlPath "c:/users/test" `shouldEqual` "c:/users/test"
35+
36+
Spec.it "encodes colon in non-drive-letter segments" do
37+
-- A colon not at the start as a drive letter should be encoded
38+
encodeFileUrlPath "/path/file:name/test" `shouldEqual` "/path/file%3Aname/test"
39+
40+
Spec.it "leaves normal paths unchanged" do
41+
encodeFileUrlPath "/home/user/project/output" `shouldEqual` "/home/user/project/output"

0 commit comments

Comments
 (0)