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) {
630630 }
631631 }
632632 }
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
163163 return fileList, nil
164164 }
165165
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+
166172 func (h *UploadAssetHandler) Validate(s *pssh.SSHServerConnSession) error {
167173 logger := pssh.GetLogger(s)
168174 user := pssh.GetUser(s)
......@@ -305,6 +311,9 @@ func (h *UploadAssetHandler) write(s *pssh.SSHServerConnSession, entry *sendutil
305311
306312 projectName := shared.GetProjectName(entry)
307313 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+ }
308317
309318 project, denylist, err := h.sessionProject(state, user, projectName, logger)
310319 if err != nil {
......@@ -465,11 +474,7 @@ func (h *UploadAssetHandler) Delete(s *pssh.SSHServerConnSession, entry *senduti
465474
466475 func (h *UploadAssetHandler) validateAsset(data *FileData) (bool, error) {
467476 fname := filepath.Base(data.Filepath)
468-
469477 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- }
473478
474479 // special files we use for custom routing
475480 if isSpecialFile(fname) {
+3, -0
......@@ -47,7 +47,10 @@ func (s *storageFS) Stat(name string) (rsync.FileInfo, error) {
4747 return rsync.FileInfo{Name: name, ModTime: e.ModTime(), IsDir: true}, nil
4848 }
4949 }
50+ d, implicit := s.handler.(utils.ImplicitDirs)
5051 switch {
52+ case len(entries) == 0 && implicit && d.IsImplicitDir(s.session, abs(name)):
53+ return rsync.FileInfo{Name: name, IsDir: true}, nil
5154 case len(entries) == 0:
5255 return rsync.FileInfo{}, fmt.Errorf("%s: %w", name, fs.ErrNotExist)
5356 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 {
134134 }
135135
136136 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) {
137147 switch r.Method {
138- case "List", "Stat":
148+ case "List", "Stat", "Lstat":
139149 list := r.Method == "List"
140150
141151 if !list {
......@@ -167,6 +177,9 @@ func (f *handler) Filelist(r *sftp.Request) (sftp.ListerAt, error) {
167177 listData = slices.DeleteFunc(listData, func(f os.FileInfo) bool {
168178 return f.Name() != "" && f.Name() != filepath.Base(r.Filepath)
169179 })
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+ }
170183 }
171184
172185 if r.Filepath == "/" {
......@@ -586,6 +599,13 @@ func (f *handlererr) Filelist(r *sftp.Request) (sftp.ListerAt, error) {
586599 }
587600 return result, err
588601 }
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+}
589609 func (f *handlererr) Filewrite(r *sftp.Request) (io.WriterAt, error) {
590610 result, err := f.Handler.Filewrite(r)
591611 if err != nil {
+7, -0
......@@ -84,6 +84,13 @@ type FileRenamer interface {
8484 Rename(s *pssh.SSHServerConnSession, from, to *FileEntry) error
8585 }
8686
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+
8794 func KeyText(session *pssh.SSHServerConnSession) (string, error) {
8895 if session.PublicKey() == nil {
8996 return "", fmt.Errorf("session doesn't have public key")
+0, -5
......@@ -25,11 +25,6 @@ func GetProjectName(entry *utils.FileEntry) string {
2525 } else if len(list) == 1 {
2626 return list[0]
2727 }
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- }
3328 return list[1]
3429 }
3530
+4, -16
......@@ -27,18 +27,12 @@ func TestGetProjectName(t *testing.T) {
2727 isDir: false,
2828 want: "myproject",
2929 },
30- // Root-level file (the bug case): /bin -> bin
30+ // A file at the root is outside every project
3131 {
32- name: "root-level file (scp file pgs.sh:/bin)",
32+ name: "root-level file",
3333 filepath: "/bin",
3434 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: "",
4236 },
4337 // Directory cases
4438 {
......@@ -47,12 +41,6 @@ func TestGetProjectName(t *testing.T) {
4741 isDir: true,
4842 want: "myproject",
4943 },
50- {
51- name: "directory at root level",
52- filepath: "/myproject",
53- isDir: true,
54- want: "myproject",
55- },
5644 // Edge cases (caught by uploader validation, not valid inputs)
5745 {
5846 name: "empty path",
......@@ -64,7 +52,7 @@ func TestGetProjectName(t *testing.T) {
6452 name: "root path",
6553 filepath: "/",
6654 isDir: false,
67- want: "/",
55+ want: "",
6856 },
6957 }
7058