diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..784ab38 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Go sources and scripts are committed with LF; a Windows checkout must not get CRLF (gofmt, sh). +* text=auto eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 535668a..8af468a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,9 +18,12 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest] + os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} timeout-minutes: 20 + defaults: + run: + shell: bash steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 @@ -32,6 +35,13 @@ jobs: - name: Install mercurial and subversion (macOS) if: runner.os == 'macOS' run: brew install mercurial subversion + - name: Install mercurial and subversion (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + choco install -y --no-progress hg sliksvn + Add-Content $env:GITHUB_PATH 'C:\Program Files\Mercurial' + Add-Content $env:GITHUB_PATH 'C:\Program Files\SlikSvn\bin' - name: Versions run: go version && git --version && hg --version | head -1 && svn --version --quiet - name: Build diff --git a/changes/unreleased/windows-paths.fixed.md b/changes/unreleased/windows-paths.fixed.md new file mode 100644 index 0000000..cbe6ff4 --- /dev/null +++ b/changes/unreleased/windows-paths.fixed.md @@ -0,0 +1 @@ +- On Windows, a repository on this machine is accepted as `git+file:///C:/path` (and `git+file://C:/path`), `file://` resources with a drive letter resolve, and the suite runs on Windows; CI gates on Windows with hg and svn installed. diff --git a/internal/install/install_test.go b/internal/install/install_test.go index adc74e2..2e902f8 100644 --- a/internal/install/install_test.go +++ b/internal/install/install_test.go @@ -10,6 +10,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" "testing" "time" @@ -297,7 +298,7 @@ func TestLockMatchesAcrossGitSuffixSpellings(t *testing.T) { } first, _ := os.ReadFile(filepath.Join(dir, project.LockFile)) // An older lock recorded the source in its input spelling; it is still the same location. - oldSrc := strings.ReplaceAll(string(first), `"source": "`+strings.ReplaceAll(filepath.FromSlash(widgets), `\\`, `\\\\`)+`"`, `"source": "git+file://`+filepath.ToSlash(widgets)+`"`) + oldSrc := strings.ReplaceAll(string(first), `"source": "`+strings.ReplaceAll(filepath.FromSlash(widgets), "\\", "\\\\")+`"`, `"source": "git+file://`+filepath.ToSlash(widgets)+`"`) if oldSrc == string(first) { t.Fatalf("lock did not contain the widgets source:\n%s", first) } @@ -549,13 +550,16 @@ func TestPublishRollsBackWhenTheLockCannotBeWritten(t *testing.T) { if os.Geteuid() == 0 { t.Skip("root ignores directory permissions") } + if runtime.GOOS == "windows" { + t.Skip("a read-only directory does not refuse new files on Windows") + } widgets, _ := fixture(t) - dir := consumer(t, project.Usage{Resource: "git+file://" + widgets + "@1.0.0"}) + dir := consumer(t, project.Usage{Resource: "git+file://" + filepath.ToSlash(widgets) + "@1.0.0"}) run(t, dir, Options{}) before, _ := os.ReadFile(filepath.Join(dir, project.LockFile)) // Move to 1.1.0 (replaces the installed tree) with the project directory read-only, // so the lock cannot be written after lib/ was updated. - write(t, dir, project.ProjectFile, `{"name":"app","version":"0.1.0","usage":[{"resource":"git+file://`+widgets+`@1.1.0"}]}`) + write(t, dir, project.ProjectFile, `{"name":"app","version":"0.1.0","usage":[{"resource":"git+file://`+filepath.ToSlash(widgets)+`@1.1.0"}]}`) if err := os.Chmod(dir, 0o555); err != nil { t.Fatal(err) } diff --git a/internal/origin/origin.go b/internal/origin/origin.go index 5d04e0d..e6e9382 100644 --- a/internal/origin/origin.go +++ b/internal/origin/origin.go @@ -189,7 +189,8 @@ func Parse(resource string) (*Origin, error) { return parseHTTP(res) } if rest, ok := strings.CutPrefix(res, "file://"); ok { - if i := strings.IndexByte(rest, '/'); i > 0 { + rest, ok = fileURIPath(rest) + if !ok { return nil, fmt.Errorf("resource %q: file URIs must name a path on this machine (file:///path)", res) } return parseLocal(res, rest) @@ -270,7 +271,8 @@ func parseGeneric(res, tail string) (*Origin, error) { // git+https:///. func parseGitAlias(res, tail string) (*Origin, error) { if strings.HasPrefix(tail, "/") { - return nil, fmt.Errorf("resource %q: pkg:git needs a host; a repository on this machine is git+file:///%s", res, strings.TrimLeft(tail, "/")) + abs, _ := filepath.Abs(filepath.FromSlash("/" + strings.TrimLeft(tail, "/"))) + return nil, fmt.Errorf("resource %q: pkg:git needs a host; a repository on this machine is git+file://%s", res, FileURIPath(abs)) } if tail == "" || !strings.Contains(tail, "/") { return nil, fmt.Errorf("resource %q: expected pkg:git//[@][#]", res) @@ -299,6 +301,12 @@ func parseVCSURL(res, raw string) (*Origin, error) { if !transports[vcs][transport] { return nil, fmt.Errorf("resource %q: %s+%s is not a vcs_url scheme for %s (ECMA-427 Annex B.3.5 allows %s)", res, vcs, transport, vcs, allowed(vcs)) } + if transport == "file" { + // file:///C:/path is the RFC 8089 form; file://C:/path is accepted as input. + if drive.MatchString(rest) { + rest = "/" + rest + } + } u, err := url.Parse(transport + "://" + rest) if err != nil { return nil, fmt.Errorf("resource %q: not a valid URL: %v", res, err) @@ -314,11 +322,15 @@ func parseVCSURL(res, raw string) (*Origin, error) { if u.Host != "" || !strings.HasPrefix(u.Path, "/") { return nil, fmt.Errorf("resource %q: a repository on this machine is %s+file:///absolute/path", res, vcs) } - o.Path = filepath.ToSlash(filepath.Clean(u.Path)) + o.Path = u.Path + if drive.MatchString(o.Path[1:]) { + o.Path = o.Path[1:] + } + o.Path = filepath.ToSlash(filepath.Clean(o.Path)) o.Name = strings.TrimSuffix(path.Base(o.Path), ".git") switch vcs { case "svn": - o.Source = "file://" + o.Path + o.Source = "file://" + FileURIPath(o.Path) default: o.Source = filepath.FromSlash(o.Path) } @@ -387,6 +399,32 @@ func parseHTTP(res string) (*Origin, error) { return &Origin{Kind: kind, Source: spec, Version: ver, Resource: res}, nil } +// drive matches the start of a Windows path, C:/ or C:\. +var drive = regexp.MustCompile(`^[A-Za-z]:[/\\]`) + +// FileURIPath is the path part of a file:// URI naming abs: /C:/dir on Windows, /dir elsewhere. +func FileURIPath(abs string) string { + abs = filepath.ToSlash(abs) + if !strings.HasPrefix(abs, "/") { + abs = "/" + abs + } + return abs +} + +// fileURIPath turns what follows file:// into a path on this machine; a host is refused. +func fileURIPath(rest string) (string, bool) { + if drive.MatchString(rest) { + return rest, true + } + if !strings.HasPrefix(rest, "/") { + return "", false + } + if drive.MatchString(rest[1:]) { + return rest[1:], true + } + return rest, true +} + // parseLocal handles file:// URIs and plain paths: a .kpar is a local // archive, anything else an editable project directory. func parseLocal(res, p string) (*Origin, error) { @@ -406,7 +444,7 @@ func parseLocal(res, p string) (*Origin, error) { } if ver != "" { abs, _ := filepath.Abs(p) - return nil, fmt.Errorf("resource %q: a path with @ is not an identity; a repository on this machine is git+file://%s@%s (or svn+file, hg+file)", res, filepath.ToSlash(abs), ver) + return nil, fmt.Errorf("resource %q: a path with @ is not an identity; a repository on this machine is git+file://%s@%s (or svn+file, hg+file)", res, FileURIPath(abs), ver) } if sub != "" { p = path.Join(p, sub) diff --git a/internal/origin/origin_test.go b/internal/origin/origin_test.go index e44f963..ede937d 100644 --- a/internal/origin/origin_test.go +++ b/internal/origin/origin_test.go @@ -3,6 +3,7 @@ package origin import ( + "path/filepath" "strings" "testing" ) @@ -26,7 +27,7 @@ func TestCanonical(t *testing.T) { {"svn://svn.example.org/pkg@1.0.0", "pkg:generic/pkg?vcs_url=svn%2Bsvn:%2F%2Fsvn.example.org%2Fpkg%401.0.0", "svn://svn.example.org/pkg"}, {"svn+https://svn.example.org/repos/pkg", "pkg:generic/pkg?vcs_url=svn%2Bhttps:%2F%2Fsvn.example.org%2Frepos%2Fpkg", "https://svn.example.org/repos/pkg"}, {"svn+ssh://svn.example.org/repos/pkg", "pkg:generic/pkg?vcs_url=svn%2Bssh:%2F%2Fsvn.example.org%2Frepos%2Fpkg", "svn+ssh://svn.example.org/repos/pkg"}, - {"git+file:///srv/git/widgets@1.0.0#packages/gears", "pkg:generic/widgets?vcs_url=git%2Bfile:%2F%2F%2Fsrv%2Fgit%2Fwidgets%401.0.0%23packages%2Fgears", "/srv/git/widgets"}, + {"git+file:///srv/git/widgets@1.0.0#packages/gears", "pkg:generic/widgets?vcs_url=git%2Bfile:%2F%2F%2Fsrv%2Fgit%2Fwidgets%401.0.0%23packages%2Fgears", filepath.FromSlash("/srv/git/widgets")}, {"svn+file:///srv/svn/widgets", "pkg:generic/widgets?vcs_url=svn%2Bfile:%2F%2F%2Fsrv%2Fsvn%2Fwidgets", "file:///srv/svn/widgets"}, } { o, err := Parse(tc.in) @@ -67,6 +68,9 @@ func TestArchivesAndDirectories(t *testing.T) { } } +// srvGitWidgets is /srv/git/widgets as a file-URI path on this machine (/D:/srv/git/widgets on Windows). +var srvGitWidgets = func() string { abs, _ := filepath.Abs(filepath.FromSlash("/srv/git/widgets")); return FileURIPath(abs) }() + func TestRefused(t *testing.T) { for _, tc := range []struct{ in, want string }{ {"pkg:svn/svn.example.org/pkg@1.0.0", "pkg:svn is not a Package-URL type; write pkg:generic/?vcs_url="}, @@ -78,9 +82,9 @@ func TestRefused(t *testing.T) { {"https://github.com/acme/widgets.git", "only dereferenced as a project interchange file"}, {"git@github.com:acme/widgets.git@1.0.0", "write git+ssh://github.com/acme/widgets"}, {"ssh://git@github.com/acme/widgets@1.0.0", "unsupported URL scheme; a repository is written as pkg:generic"}, - {"file:///srv/git/widgets@1.0.0", "a repository on this machine is git+file:///srv/git/widgets@1.0.0"}, - {"/srv/git/widgets@1.0.0", "a repository on this machine is git+file:///srv/git/widgets@1.0.0"}, - {"pkg:git//srv/git/widgets@1.0.0", "a repository on this machine is git+file:///srv/git/widgets@1.0.0"}, + {"file:///srv/git/widgets@1.0.0", "a repository on this machine is git+file://" + srvGitWidgets + "@1.0.0"}, + {"/srv/git/widgets@1.0.0", "a repository on this machine is git+file://" + srvGitWidgets + "@1.0.0"}, + {"pkg:git//srv/git/widgets@1.0.0", "a repository on this machine is git+file://" + srvGitWidgets + "@1.0.0"}, {"git+https://user:pw@github.com/acme/widgets", "user name or password in the host is not supported (ECMA-427 Annex B.3.5)"}, {"hg://hg.example.org/repo", "hg+hg is not a vcs_url scheme for hg (ECMA-427 Annex B.3.5 allows hg+http, hg+https, hg+static-http, hg+ssh, hg+file)"}, {"git+ftp://host/repo", "git+ftp is not a vcs_url scheme for git"}, diff --git a/internal/vcs/git.go b/internal/vcs/git.go index e3676db..661d122 100644 --- a/internal/vcs/git.go +++ b/internal/vcs/git.go @@ -76,13 +76,13 @@ func (g *gitSource) Export(ctx context.Context, v Version, dst string) error { } defer os.RemoveAll(scratch) clone := filepath.Join(scratch, "repo") - if _, err := run(ctx, "", "git", "-c", "advice.detachedHead=false", "clone", "--quiet", "--depth", "1", "--branch", v.Tag, "--", g.url, clone); err != nil { + if _, err := run(ctx, "", "git", "-c", "advice.detachedHead=false", "-c", "core.autocrlf=false", "clone", "--quiet", "--depth", "1", "--branch", v.Tag, "--", g.url, clone); err != nil { if !strings.Contains(err.Error(), "shallow") { return err } // A dumb HTTP remote cannot serve a shallow clone; take the full history. os.RemoveAll(clone) - if _, err := run(ctx, "", "git", "-c", "advice.detachedHead=false", "clone", "--quiet", "--branch", v.Tag, "--", g.url, clone); err != nil { + if _, err := run(ctx, "", "git", "-c", "advice.detachedHead=false", "-c", "core.autocrlf=false", "clone", "--quiet", "--branch", v.Tag, "--", g.url, clone); err != nil { return err } } diff --git a/internal/vcs/hg.go b/internal/vcs/hg.go index 1841d63..c72a98d 100644 --- a/internal/vcs/hg.go +++ b/internal/vcs/hg.go @@ -26,10 +26,12 @@ func (h *hgSource) ListVersions(ctx context.Context) ([]Version, error) { } defer os.RemoveAll(scratch) clone := filepath.Join(scratch, "repo") - if _, err := run(ctx, "", "hg", "clone", "--quiet", "--noupdate", "--", h.url, clone); err != nil { + // --noninteractive: hg would otherwise prompt for HTTP credentials (and on Windows + // takes the NUL stdin for a terminal); the abort then names the authorization. + if _, err := run(ctx, "", "hg", "--noninteractive", "clone", "--quiet", "--noupdate", "--", h.url, clone); err != nil { return nil, err } - out, err := run(ctx, clone, "hg", "tags", "--quiet") + out, err := run(ctx, clone, "hg", "--noninteractive", "tags", "--quiet") if err != nil { return nil, err } @@ -44,7 +46,7 @@ func (h *hgSource) ListVersions(ctx context.Context) ([]Version, error) { // Revision is the 40-hex changeset node id the tag points at. func (h *hgSource) Revision(ctx context.Context, v Version) (string, error) { - out, err := run(ctx, "", "hg", "identify", "--id", "--debug", "-r", v.Tag, "--", h.url) + out, err := run(ctx, "", "hg", "--noninteractive", "identify", "--id", "--debug", "-r", v.Tag, "--", h.url) if err != nil { return "", err } @@ -76,13 +78,13 @@ func (h *hgSource) Export(ctx context.Context, v Version, dst string) error { } defer os.RemoveAll(scratch) clone := filepath.Join(scratch, "repo") - if _, err := run(ctx, "", "hg", "clone", "--quiet", "--noupdate", "-r", node, "--", h.url, clone); err != nil { + if _, err := run(ctx, "", "hg", "--noninteractive", "clone", "--quiet", "--noupdate", "-r", node, "--", h.url, clone); err != nil { return err } // --no-decode keeps bytes as committed. The .hg_archival.txt stamp and the // .hgtags bookkeeping file are Mercurial's own, not the package's, and are // excluded so the tree hash matches the same package served from git or svn. - if _, err := run(ctx, clone, "hg", "archive", "--quiet", "--type", "files", "-r", node, "--no-decode", "--exclude", ".hg_archival.txt", "--exclude", ".hgtags", dst); err != nil { + if _, err := run(ctx, clone, "hg", "--noninteractive", "archive", "--quiet", "--type", "files", "-r", node, "--no-decode", "--exclude", ".hg_archival.txt", "--exclude", ".hgtags", dst); err != nil { return err } return nil diff --git a/internal/vcs/svn.go b/internal/vcs/svn.go index 926e390..90cf221 100644 --- a/internal/vcs/svn.go +++ b/internal/vcs/svn.go @@ -69,7 +69,8 @@ func svnAuthArgsFor(repo string, lookup func(string) (netrc.Credential, bool), f if !ok || c.Login == "" || !fromStdin { return nil, nil } - return []string{"--username", c.Login, "--password-from-stdin", "--no-auth-cache"}, strings.NewReader(c.Password + "\n") + // No trailing newline: svn on Windows keeps a bare LF as part of the password. + return []string{"--username", c.Login, "--password-from-stdin", "--no-auth-cache"}, strings.NewReader(c.Password) } // svn runs the user's svn with the netrc credentials for the repository diff --git a/internal/vcs/svn_auth_test.go b/internal/vcs/svn_auth_test.go index 464bc40..f42904c 100644 --- a/internal/vcs/svn_auth_test.go +++ b/internal/vcs/svn_auth_test.go @@ -21,7 +21,7 @@ func TestSvnAuthArgsFor(t *testing.T) { if strings.Join(args, " ") != "--username alice --password-from-stdin --no-auth-cache" { t.Fatalf("args %q", args) } - if pw, _ := io.ReadAll(stdin); string(pw) != "s3cret\n" { + if pw, _ := io.ReadAll(stdin); string(pw) != "s3cret" { t.Fatalf("stdin %q", pw) } for _, a := range args { diff --git a/internal/vcs/vcs_test.go b/internal/vcs/vcs_test.go index ccc72ce..abfae2c 100644 --- a/internal/vcs/vcs_test.go +++ b/internal/vcs/vcs_test.go @@ -98,12 +98,21 @@ func hgFixture(t *testing.T) string { return repo } +// fileURL is the file:// URL of an absolute path on this machine (file:///C:/x on Windows). +func fileURL(p string) string { + p = filepath.ToSlash(p) + if !strings.HasPrefix(p, "/") { + p = "/" + p + } + return "file://" + p +} + func svnFixture(t *testing.T) string { t.Helper() root := t.TempDir() repo := filepath.Join(root, "repo") sh(t, root, nil, "svnadmin", "create", repo) - url := "file://" + filepath.ToSlash(repo) + url := fileURL(repo) stage := filepath.Join(root, "stage") os.MkdirAll(filepath.Join(stage, "trunk"), 0o755) os.MkdirAll(filepath.Join(stage, "tags"), 0o755) diff --git a/tests/e2e/auth_test.go b/tests/e2e/auth_test.go index 6179a04..28cc109 100644 --- a/tests/e2e/auth_test.go +++ b/tests/e2e/auth_test.go @@ -61,7 +61,7 @@ func TestAuthGitCredentialHelper(t *testing.T) { // With a credential helper configured through the environment, as a user would. helper := filepath.Join(root, "helper.sh") os.WriteFile(helper, []byte("#!/bin/sh\necho username=alice\necho password=s3cret\n"), 0o755) - env := []string{"GIT_CONFIG_COUNT=1", "GIT_CONFIG_KEY_0=credential.helper", "GIT_CONFIG_VALUE_0=" + helper} + env := []string{"GIT_CONFIG_COUNT=1", "GIT_CONFIG_KEY_0=credential.helper", "GIT_CONFIG_VALUE_0=" + filepath.ToSlash(helper)} app2 := initApp(t, root, "app-cred") if out, err := shErr(app2, env, bin, "add", res); err != nil { t.Fatalf("add with helper: %v\n%s", err, out) @@ -120,11 +120,16 @@ func TestAuthSvnNetrc(t *testing.T) { requireOrSkip(t, "svn", "svnadmin", "svnserve") bin := syspiBinary(t) root := t.TempDir() - url := svnFixture(t, root) - repo := strings.TrimPrefix(url, "file://") - conf := filepath.Join(repo, "conf") - os.WriteFile(filepath.Join(conf, "svnserve.conf"), []byte("[general]\nanon-access = none\nauth-access = read\npassword-db = passwd\nrealm = pkg\n"), 0o644) - os.WriteFile(filepath.Join(conf, "passwd"), []byte("[users]\nalice = s3cret\n"), 0o644) + svnFixture(t, root) + conf := filepath.Join(root, "widgets-svn", "conf") + for name, content := range map[string]string{ + "svnserve.conf": "[general]\nanon-access = none\nauth-access = read\npassword-db = passwd\nrealm = pkg\n", + "passwd": "[users]\nalice = s3cret\n", + } { + if err := os.WriteFile(filepath.Join(conf, name), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } port := freePort(t) pid := filepath.Join(root, "svnserve.pid") cmd := exec.Command("svnserve", "-d", "--foreground", "--listen-host", "127.0.0.1", "--listen-port", strconv.Itoa(port), "-r", root, "--pid-file", pid) diff --git a/tests/e2e/e2e_test.go b/tests/e2e/e2e_test.go index 12d2543..26a0cee 100644 --- a/tests/e2e/e2e_test.go +++ b/tests/e2e/e2e_test.go @@ -13,6 +13,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" "sync" "testing" @@ -36,6 +37,9 @@ func syspiBinary(t *testing.T) string { return } binPath = filepath.Join(dir, "syspi") + if runtime.GOOS == "windows" { + binPath += ".exe" + } cmd := exec.Command("go", "build", "-o", binPath, "../../cmd/syspi") if out, err := cmd.CombinedOutput(); err != nil { buildErr = &buildFailure{err, string(out)} @@ -145,10 +149,19 @@ func hgFixture(t *testing.T, root string) string { return repo } +// fileURL is the file:// URL of an absolute path on this machine (file:///C:/x on Windows). +func fileURL(p string) string { + p = filepath.ToSlash(p) + if !strings.HasPrefix(p, "/") { + p = "/" + p + } + return "file://" + p +} + func svnFixture(t *testing.T, root string) string { repo := filepath.Join(root, "widgets-svn") sh(t, root, "svnadmin", "create", repo) - url := "file://" + filepath.ToSlash(repo) + url := fileURL(repo) stage := filepath.Join(root, "svn-stage") os.MkdirAll(filepath.Join(stage, "tags"), 0o755) packageTree(t, filepath.Join(stage, "trunk"), "1.0.0") @@ -383,7 +396,7 @@ func TestRefusals(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - app := filepath.Join(root, "app-"+strings.ReplaceAll(tc.name, " ", "-")) + app := filepath.Join(root, "app-"+strings.NewReplacer(" ", "-", ":", "-", "/", "-").Replace(tc.name)) os.MkdirAll(app, 0o755) sh(t, app, bin, "init", "-name", "app") before, _ := os.ReadFile(filepath.Join(app, ".project.json"))