Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ what package managers install.
plus `file` for a repository on this machine; `<name>` is the repository's last path segment.
Two shorter spellings are accepted as input and normalised to it: `pkg:git/<host>/<path>[@<version>][#<subpath>]`
(git over https) and the bare notation `git+https://host/path@1.2.0`, `svn://host/repo@1.2.0`,
`hg+https://…`, `git+file:///srv/repo@1.2.0`. Refused with a message naming those three:
`hg+https://…`, `git+file:///srv/repo@1.2.0` (`git+file:///C:/repo@1.2.0` on Windows). Refused with a message naming those three:
`pkg:svn/…`, `pkg:hg/…`, `pkg:github/…`, plain clone URLs (`https://host/path.git`, `ssh://…`,
`git@host:path`, `file:///path@1.2.0`) and a commit id as version. SysPI hand-parses this one
purl shape and uses no purl library. A plain `http(s)` IRI is only ever read as a project
Expand Down
1 change: 1 addition & 0 deletions changes/unreleased/windows-file-uris.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- A drive letter in a `file://` resource is only a drive on Windows; on Linux and macOS `file:///C:/tools.kpar` names the directory `C:` again. The Windows spellings are documented, and the lock-rollback test runs on Windows too.
5 changes: 4 additions & 1 deletion docs/packages.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ Two input spellings normalise to it (and `syspi add` writes the canonical form i
`.project.json`): `pkg:git/<host>/<path>[@<version>][#<subpath>]`, meaning `git+https`; and the
bare vcs_url notation `git+https://…`, `git+ssh://…`, `git://…`, `hg+https://…`,
`hg+static-http://…`, `svn://…`, `svn+https://…`, `svn+ssh://…`, `<vcs>+file:///…`, each with an
optional `@<version>` and `#<subpath>`. The version-free identity (`vcs_url` without `@<version>`)
optional `@<version>` and `#<subpath>`. A repository on a Windows machine is
`<vcs>+file:///C:/path` (RFC 8089); `<vcs>+file://C:/path` is accepted as input and normalised to
it, and the same two forms name a local `.kpar` or project directory (`file:///C:/tools.kpar`).
On other systems `/C:/path` is an ordinary directory named `C:`. The version-free identity (`vcs_url` without `@<version>`)
is what the lock records as `resource`; what the VCS executable is given is kept as `source`
(`git+ssh` stays SSH; the path is cloned exactly as written, so `…/repo` and `…/repo.git` are
two locations and the one your server serves is the one to write).
Expand Down
17 changes: 10 additions & 7 deletions internal/install/install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -550,24 +550,27 @@ 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://" + 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://`+filepath.ToSlash(widgets)+`@1.1.0"}]}`)
if err := os.Chmod(dir, 0o555); err != nil {
// Windows ignores directory modes but refuses to replace a read-only file.
locked, mode := dir, os.FileMode(0o555)
if runtime.GOOS == "windows" {
locked, mode = filepath.Join(dir, project.LockFile), 0o444
}
if err := os.Chmod(locked, mode); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.Chmod(dir, 0o755) })
t.Cleanup(func() { os.Chmod(locked, mode|0o200) })
opts := Options{Root: dir}
_, err := Run(context.Background(), opts)
os.Chmod(dir, 0o755)
if err == nil || !strings.Contains(err.Error(), "permission denied") || !strings.Contains(err.Error(), project.LockFile) {
os.Chmod(locked, mode|0o200)
denied := err != nil && (strings.Contains(err.Error(), "permission denied") || strings.Contains(err.Error(), "Access is denied"))
if !denied || !strings.Contains(err.Error(), project.LockFile) {
t.Fatalf("the lock write must be what failed: %v", err)
}
after, _ := os.ReadFile(filepath.Join(dir, project.LockFile))
Expand Down
15 changes: 11 additions & 4 deletions internal/origin/origin.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"path"
"path/filepath"
"regexp"
"runtime"
"strings"

"github.com/Open-MBEE/SysPI/internal/version"
Expand Down Expand Up @@ -303,7 +304,7 @@ func parseVCSURL(res, raw string) (*Origin, error) {
}
if transport == "file" {
// file:///C:/path is the RFC 8089 form; file://C:/path is accepted as input.
if drive.MatchString(rest) {
if isDrivePath(rest) {
rest = "/" + rest
}
}
Expand All @@ -323,7 +324,7 @@ func parseVCSURL(res, raw string) (*Origin, error) {
return nil, fmt.Errorf("resource %q: a repository on this machine is %s+file:///absolute/path", res, vcs)
}
o.Path = u.Path
if drive.MatchString(o.Path[1:]) {
if isDrivePath(o.Path[1:]) {
o.Path = o.Path[1:]
}
o.Path = filepath.ToSlash(filepath.Clean(o.Path))
Expand Down Expand Up @@ -402,6 +403,12 @@ func parseHTTP(res string) (*Origin, error) {
// drive matches the start of a Windows path, C:/ or C:\.
var drive = regexp.MustCompile(`^[A-Za-z]:[/\\]`)

// windowsPaths is whether this machine spells absolute paths with a drive; on
// other systems /C:/dir is an ordinary directory named C:.
var windowsPaths = runtime.GOOS == "windows"

func isDrivePath(p string) bool { return windowsPaths && drive.MatchString(p) }

// 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)
Expand All @@ -413,13 +420,13 @@ func FileURIPath(abs string) string {

// 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) {
if isDrivePath(rest) {
return rest, true
}
if !strings.HasPrefix(rest, "/") {
return "", false
}
if drive.MatchString(rest[1:]) {
if isDrivePath(rest[1:]) {
return rest[1:], true
}
return rest, true
Expand Down
38 changes: 38 additions & 0 deletions internal/origin/origin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,41 @@ func TestKey(t *testing.T) {
t.Errorf("archive key %s != identity %s", o.Key(), o.Identity())
}
}

// A drive letter is only a drive on Windows; elsewhere /C:/dir is a directory named C:.
func TestDriveLetterFileURIs(t *testing.T) {
defer func(w bool) { windowsPaths = w }(windowsPaths)
for _, tc := range []struct {
windows bool
in, path, src string
}{
{true, "git+file:///C:/srv/widgets@1.0.0", "C:/srv/widgets", ""},
{true, "git+file://C:/srv/widgets@1.0.0", "C:/srv/widgets", ""},
{true, "svn+file:///C:/srv/widgets@1.0.0", "C:/srv/widgets", "file:///C:/srv/widgets"},
{false, "git+file:///C:/srv/widgets@1.0.0", "/C:/srv/widgets", ""},
{false, "svn+file:///C:/srv/widgets@1.0.0", "/C:/srv/widgets", "file:///C:/srv/widgets"},
} {
if tc.src == "" { // git: the path in this machine's separators, whichever machine is simulated
tc.src = filepath.FromSlash(tc.path)
}
windowsPaths = tc.windows
o, err := Parse(tc.in)
if err != nil {
t.Errorf("windows=%v %s: %v", tc.windows, tc.in, err)
continue
}
if o.Path != tc.path || o.Source != tc.src {
t.Errorf("windows=%v %s: path %q source %q, want %q %q", tc.windows, tc.in, o.Path, o.Source, tc.path, tc.src)
}
if _, err := Parse(o.Canonical()); err != nil {
t.Errorf("windows=%v canonical %s: %v", tc.windows, o.Canonical(), err)
}
}
windowsPaths = false
if _, err := Parse("file://C:/tools.kpar"); err == nil {
t.Error("file://C:/… must be refused where C: is a host, not a drive")
}
if o, err := Parse("file:///C:/tools.kpar"); err != nil || o.Source != "/C:/tools.kpar" {
t.Errorf("file:///C:/tools.kpar: %v %+v", err, o)
}
}
Loading