Commit cd3a2a1
Antonio Mika
·
2026-10-10 23:05:15 -0400 EDT
parent 4e51b51
fix(pgs): copy a single file into a project that doesn't exist yet
7 files changed,
+91,
-26
+47,
-0
| ... | ... | @@ -630,3 +630,50 @@ func testSshfs(t *testing.T, env *rsyncEnv) { | |
| 630 | 630 | } | |
| 631 | 631 | } | |
| 632 | 632 | } | |
| 633 | + | ||
| 634 | + | func TestSingleFileIntoProject(t *testing.T) { | |
| 635 | + | newStorage := rsyncStorageBackends()["memory"] | |
| 636 | + | clients := map[string]func(e *rsyncEnv, src, dest string) *exec.Cmd{ | |
| 637 | + | "scp-legacy": func(e *rsyncEnv, src, dest string) *exec.Cmd { | |
| 638 | + | return exec.Command("scp", append(append([]string{"-O"}, e.sshArgs...), src, "localhost:"+dest)...) | |
| 639 | + | }, | |
| 640 | + | "scp-sftp": func(e *rsyncEnv, src, dest string) *exec.Cmd { | |
| 641 | + | return exec.Command("scp", append(e.sshArgs, src, "localhost:"+dest)...) | |
| 642 | + | }, | |
| 643 | + | "sftp": func(e *rsyncEnv, src, dest string) *exec.Cmd { | |
| 644 | + | return e.sftp("put " + src + " " + dest) | |
| 645 | + | }, | |
| 646 | + | "rsync": func(e *rsyncEnv, src, dest string) *exec.Cmd { | |
| 647 | + | return exec.Command("rsync", "-e", e.rsh, src, "localhost:"+dest) | |
| 648 | + | }, | |
| 649 | + | } | |
| 650 | + | for name, client := range clients { | |
| 651 | + | for _, dest := range []string{"/site", "/site/"} { | |
| 652 | + | t.Run(name+" "+dest, func(t *testing.T) { | |
| 653 | + | env := newRsyncEnv(t, newStorage(t)) | |
| 654 | + | src := tree{t, t.TempDir()} | |
| 655 | + | src.write("post.md", []byte("hello"), time.Now()) | |
| 656 | + | out, code := runCmd(t, client(env, filepath.Join(src.dir, "post.md"), dest)) | |
| 657 | + | if code != 0 { | |
| 658 | + | t.Fatalf("exit %d\n%s", code, out) | |
| 659 | + | } | |
| 660 | + | env.assertStored(map[string]string{"post.md": "hello"}) | |
| 661 | + | if !strings.Contains(out, "https://testusr-site.pgs.sh/post.md ") { | |
| 662 | + | t.Errorf("wrong url\n%s", out) | |
| 663 | + | } | |
| 664 | + | }) | |
| 665 | + | } | |
| 666 | + | t.Run(name+" /", func(t *testing.T) { | |
| 667 | + | env := newRsyncEnv(t, newStorage(t)) | |
| 668 | + | src := tree{t, t.TempDir()} | |
| 669 | + | src.write("post.md", []byte("hello"), time.Now()) | |
| 670 | + | out, code := runCmd(t, client(env, filepath.Join(src.dir, "post.md"), "/")) | |
| 671 | + | if code == 0 { | |
| 672 | + | t.Errorf("file at the root exited 0\n%s", out) | |
| 673 | + | } | |
| 674 | + | if !strings.Contains(out, "you must copy files to a non-root folder") { | |
| 675 | + | t.Errorf("no root error\n%s", out) | |
| 676 | + | } | |
| 677 | + | }) | |
| 678 | + | } | |
| 679 | + | } |
+9,
-4
| ... | ... | @@ -163,6 +163,12 @@ func (h *UploadAssetHandler) List(s *pssh.SSHServerConnSession, fpath string, is | |
| 163 | 163 | return fileList, nil | |
| 164 | 164 | } | |
| 165 | 165 | ||
| 166 | + | // IsImplicitDir reports top-level project names. A project holds its files | |
| 167 | + | // whether or not it exists yet. | |
| 168 | + | func (h *UploadAssetHandler) IsImplicitDir(_ *pssh.SSHServerConnSession, fpath string) bool { | |
| 169 | + | return shared.IsValidSubdomain(strings.Trim(fpath, "/")) | |
| 170 | + | } | |
| 171 | + | ||
| 166 | 172 | func (h *UploadAssetHandler) Validate(s *pssh.SSHServerConnSession) error { | |
| 167 | 173 | logger := pssh.GetLogger(s) | |
| 168 | 174 | user := pssh.GetUser(s) |
| ... | ... | @@ -305,6 +311,9 @@ func (h *UploadAssetHandler) write(s *pssh.SSHServerConnSession, entry *sendutil | |
| 305 | 311 | ||
| 306 | 312 | projectName := shared.GetProjectName(entry) | |
| 307 | 313 | logger = logger.With("project", projectName) | |
| 314 | + | if projectName == "" || projectName == "/" || projectName == "." { | |
| 315 | + | return "", fmt.Errorf("ERROR: invalid project name, you must copy files to a non-root folder (e.g. pgs.sh:/project-name)") | |
| 316 | + | } | |
| 308 | 317 | ||
| 309 | 318 | project, denylist, err := h.sessionProject(state, user, projectName, logger) | |
| 310 | 319 | if err != nil { |
| ... | ... | @@ -465,11 +474,7 @@ func (h *UploadAssetHandler) Delete(s *pssh.SSHServerConnSession, entry *senduti | |
| 465 | 474 | ||
| 466 | 475 | func (h *UploadAssetHandler) validateAsset(data *FileData) (bool, error) { | |
| 467 | 476 | fname := filepath.Base(data.Filepath) | |
| 468 | - | ||
| 469 | 477 | projectName := shared.GetProjectName(data.FileEntry) | |
| 470 | - | if projectName == "" || projectName == "/" || projectName == "." { | |
| 471 | - | return false, fmt.Errorf("ERROR: invalid project name, you must copy files to a non-root folder (e.g. pgs.sh:/project-name)") | |
| 472 | - | } | |
| 473 | 478 | ||
| 474 | 479 | // special files we use for custom routing | |
| 475 | 480 | if isSpecialFile(fname) { |
+3,
-0
| ... | ... | @@ -47,7 +47,10 @@ func (s *storageFS) Stat(name string) (rsync.FileInfo, error) { | |
| 47 | 47 | return rsync.FileInfo{Name: name, ModTime: e.ModTime(), IsDir: true}, nil | |
| 48 | 48 | } | |
| 49 | 49 | } | |
| 50 | + | d, implicit := s.handler.(utils.ImplicitDirs) | |
| 50 | 51 | switch { | |
| 52 | + | case len(entries) == 0 && implicit && d.IsImplicitDir(s.session, abs(name)): | |
| 53 | + | return rsync.FileInfo{Name: name, IsDir: true}, nil | |
| 51 | 54 | case len(entries) == 0: | |
| 52 | 55 | return rsync.FileInfo{}, fmt.Errorf("%s: %w", name, fs.ErrNotExist) | |
| 53 | 56 | case len(entries) == 1 && !entries[0].IsDir() && path.Base(entries[0].Name()) == path.Base(name): |
+21,
-1
| ... | ... | @@ -134,8 +134,18 @@ func (f *handler) Filecmd(r *sftp.Request) error { | |
| 134 | 134 | } | |
| 135 | 135 | ||
| 136 | 136 | func (f *handler) Filelist(r *sftp.Request) (sftp.ListerAt, error) { | |
| 137 | + | return f.filelist(r, true) | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | // Lstat is a stat that leaves out implicit directories. sshfs looks paths | |
| 141 | + | // up with it, and a mkdir there would fail if they showed as existing. | |
| 142 | + | func (f *handler) Lstat(r *sftp.Request) (sftp.ListerAt, error) { | |
| 143 | + | return f.filelist(r, false) | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | func (f *handler) filelist(r *sftp.Request, implicit bool) (sftp.ListerAt, error) { | |
| 137 | 147 | switch r.Method { | |
| 138 | - | case "List", "Stat": | |
| 148 | + | case "List", "Stat", "Lstat": | |
| 139 | 149 | list := r.Method == "List" | |
| 140 | 150 | ||
| 141 | 151 | if !list { |
| ... | ... | @@ -167,6 +177,9 @@ func (f *handler) Filelist(r *sftp.Request) (sftp.ListerAt, error) { | |
| 167 | 177 | listData = slices.DeleteFunc(listData, func(f os.FileInfo) bool { | |
| 168 | 178 | return f.Name() != "" && f.Name() != filepath.Base(r.Filepath) | |
| 169 | 179 | }) | |
| 180 | + | if d, ok := f.writeHandler.(utils.ImplicitDirs); ok && implicit && len(listData) == 0 && d.IsImplicitDir(f.session, r.Filepath) { | |
| 181 | + | listData = []os.FileInfo{&utils.VirtualFile{FName: filepath.Base(r.Filepath), FIsDir: true}} | |
| 182 | + | } | |
| 170 | 183 | } | |
| 171 | 184 | ||
| 172 | 185 | if r.Filepath == "/" { |
| ... | ... | @@ -586,6 +599,13 @@ func (f *handlererr) Filelist(r *sftp.Request) (sftp.ListerAt, error) { | |
| 586 | 599 | } | |
| 587 | 600 | return result, err | |
| 588 | 601 | } | |
| 602 | + | func (f *handlererr) Lstat(r *sftp.Request) (sftp.ListerAt, error) { | |
| 603 | + | result, err := f.Handler.Lstat(r) | |
| 604 | + | if err != nil { | |
| 605 | + | _, _ = fmt.Fprintln(f.Handler.session.Stderr(), err) | |
| 606 | + | } | |
| 607 | + | return result, err | |
| 608 | + | } | |
| 589 | 609 | func (f *handlererr) Filewrite(r *sftp.Request) (io.WriterAt, error) { | |
| 590 | 610 | result, err := f.Handler.Filewrite(r) | |
| 591 | 611 | if err != nil { |
+7,
-0
| ... | ... | @@ -84,6 +84,13 @@ type FileRenamer interface { | |
| 84 | 84 | Rename(s *pssh.SSHServerConnSession, from, to *FileEntry) error | |
| 85 | 85 | } | |
| 86 | 86 | ||
| 87 | + | // ImplicitDirs is implemented by handlers with directories that exist | |
| 88 | + | // before anything is stored in them. A stat of one reports a directory, so | |
| 89 | + | // a client copying a single file there puts the file inside it. | |
| 90 | + | type ImplicitDirs interface { | |
| 91 | + | IsImplicitDir(s *pssh.SSHServerConnSession, path string) bool | |
| 92 | + | } | |
| 93 | + | ||
| 87 | 94 | func KeyText(session *pssh.SSHServerConnSession) (string, error) { | |
| 88 | 95 | if session.PublicKey() == nil { | |
| 89 | 96 | return "", fmt.Errorf("session doesn't have public key") |
| ... | ... | @@ -25,11 +25,6 @@ func GetProjectName(entry *utils.FileEntry) string { | |
| 25 | 25 | } else if len(list) == 1 { | |
| 26 | 26 | return list[0] | |
| 27 | 27 | } | |
| 28 | - | // When filepath is like "/bin" (root-level file), dir is "/" and | |
| 29 | - | // split produces ["", ""]. Use the filename as the project name. | |
| 30 | - | if list[1] == "" { | |
| 31 | - | return filepath.Base(entry.Filepath) | |
| 32 | - | } | |
| 33 | 28 | return list[1] | |
| 34 | 29 | } | |
| 35 | 30 |
| ... | ... | @@ -27,18 +27,12 @@ func TestGetProjectName(t *testing.T) { | |
| 27 | 27 | isDir: false, | |
| 28 | 28 | want: "myproject", | |
| 29 | 29 | }, | |
| 30 | - | // Root-level file (the bug case): /bin -> bin | |
| 30 | + | // A file at the root is outside every project | |
| 31 | 31 | { | |
| 32 | - | name: "root-level file (scp file pgs.sh:/bin)", | |
| 32 | + | name: "root-level file", | |
| 33 | 33 | filepath: "/bin", | |
| 34 | 34 | isDir: false, | |
| 35 | - | want: "bin", | |
| 36 | - | }, | |
| 37 | - | { | |
| 38 | - | name: "root-level file with different name", | |
| 39 | - | filepath: "/myproject", | |
| 40 | - | isDir: false, | |
| 41 | - | want: "myproject", | |
| 35 | + | want: "", | |
| 42 | 36 | }, | |
| 43 | 37 | // Directory cases | |
| 44 | 38 | { |
| ... | ... | @@ -47,12 +41,6 @@ func TestGetProjectName(t *testing.T) { | |
| 47 | 41 | isDir: true, | |
| 48 | 42 | want: "myproject", | |
| 49 | 43 | }, | |
| 50 | - | { | |
| 51 | - | name: "directory at root level", | |
| 52 | - | filepath: "/myproject", | |
| 53 | - | isDir: true, | |
| 54 | - | want: "myproject", | |
| 55 | - | }, | |
| 56 | 44 | // Edge cases (caught by uploader validation, not valid inputs) | |
| 57 | 45 | { | |
| 58 | 46 | name: "empty path", |