mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 14:27:27 +00:00
* ci(deploy): preempt self-update timer so it can't starve the deploy lock
The self-update systemd timer redeploys from source (no SKIP_BUILD) and
can hold setup.sh's flock for >5min while compiling on a tiny droplet.
When it fires close to a CI push it starves the deploy, which waits the
full 5min on the lock and then fails with 'another setup.sh run is in
progress (lock held >5m)'.
The CI push is authoritative (ships prebuilt binaries + resets to the
exact commit), so stop any in-flight timer run to release the lock and
pause the timer before taking it. setup.sh re-enables the timer at the
end of its run.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(files): isolate over-quota test from seeded-README baseline
TestWebSaveOverQuotaPreservesExistingFile set quota=5 but left sess.used
at the newSession baseline, which already counts the README.txt that
ensureUserPub seeds into /public (added in d19c5c4). That baseline alone
exceeds 5 bytes, so the initial 2-byte save was rejected with
'quota exceeded' before the test could exercise the over-quota replace.
Reset sess.used to 0 after setting the tiny quota, mirroring
TestQuotaEnforced, so the writer starts from a clean gauge.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
317 lines
9.7 KiB
Go
317 lines
9.7 KiB
Go
package files
|
|
|
|
import (
|
|
"bytes"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/pkg/sftp"
|
|
"github.com/profullstack/agentbbs/internal/store"
|
|
)
|
|
|
|
func TestEnsureUserPubSeedsReadme(t *testing.T) {
|
|
svc, _, u := newTestService(t)
|
|
if err := svc.ensureUserPub(u.Name); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
readme := filepath.Join(svc.userPub(u.Name), "README.txt")
|
|
got, err := os.ReadFile(readme)
|
|
if err != nil {
|
|
t.Fatalf("public area not seeded with README.txt: %v", err)
|
|
}
|
|
if !bytes.Equal(got, defaultPublicReadme) {
|
|
t.Errorf("seeded README content does not match the embedded default")
|
|
}
|
|
// Re-materialization must not clobber a member's own README.
|
|
custom := []byte("this is my own readme, hands off\n")
|
|
if err := os.WriteFile(readme, custom, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := svc.ensureUserPub(u.Name); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, _ := os.ReadFile(readme); !bytes.Equal(got, custom) {
|
|
t.Errorf("ensureUserPub overwrote the member's own README: %q", got)
|
|
}
|
|
}
|
|
|
|
func newTestService(t *testing.T) (*Service, store.Store, store.User) {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
st, err := store.Open(filepath.Join(dir, "test.db"))
|
|
if err != nil {
|
|
t.Fatalf("store.Open: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = st.Close() })
|
|
svc, err := New(st, Config{Root: filepath.Join(dir, "files"), DefaultQuota: 1 << 20})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
u, err := st.EnsureUser("alice", "member", "SHA256:alicekey")
|
|
if err != nil {
|
|
t.Fatalf("EnsureUser: %v", err)
|
|
}
|
|
return svc, st, u
|
|
}
|
|
|
|
func TestResolveConfinement(t *testing.T) {
|
|
svc, _, u := newTestService(t)
|
|
sess, err := svc.newSession(u)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
priv := svc.privRoot(u.Name)
|
|
pub := svc.pubRoot()
|
|
|
|
// These must never resolve to a path outside their area root. Some are
|
|
// expected to error outright; for the rest, assert containment.
|
|
escapes := []string{
|
|
"/me/../../../etc/passwd",
|
|
"/me/../../etc",
|
|
"/me/sub/../../../../etc/shadow",
|
|
"/public/../me/secret",
|
|
"/public/../../etc",
|
|
"/../etc/passwd",
|
|
"/me/./../../public/../../root",
|
|
}
|
|
for _, p := range escapes {
|
|
res, err := sess.resolve(p)
|
|
if err != nil {
|
|
continue // rejected outright — fine
|
|
}
|
|
if res.root {
|
|
continue // collapsed to the synthetic root — fine
|
|
}
|
|
ok := within(priv, res.real) || within(pub, res.real)
|
|
if !ok {
|
|
t.Errorf("resolve(%q) escaped: %q (priv=%q pub=%q)", p, res.real, priv, pub)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResolveAreas(t *testing.T) {
|
|
svc, _, u := newTestService(t)
|
|
sess, _ := svc.newSession(u)
|
|
|
|
root, _ := sess.resolve("/")
|
|
if !root.root {
|
|
t.Error("/ should be the synthetic root")
|
|
}
|
|
me, err := sess.resolve("/me/notes.txt")
|
|
if err != nil || me.area != areaMe || !me.writable {
|
|
t.Errorf("/me should be writable me-area: %+v err=%v", me, err)
|
|
}
|
|
if !within(svc.privRoot(u.Name), me.real) {
|
|
t.Errorf("/me path %q not under priv root", me.real)
|
|
}
|
|
pub, err := sess.resolve("/public/shared.txt")
|
|
if err != nil || pub.area != areaPublic {
|
|
t.Errorf("/public should be public area: %+v err=%v", pub, err)
|
|
}
|
|
if _, err := sess.resolve("/etc/passwd"); err == nil {
|
|
t.Error("unknown top-level area should be rejected")
|
|
}
|
|
}
|
|
|
|
func TestSymlinkEscapeBlocked(t *testing.T) {
|
|
svc, _, u := newTestService(t)
|
|
sess, _ := svc.newSession(u)
|
|
priv := svc.privRoot(u.Name)
|
|
|
|
// Plant a symlink inside the workspace pointing at the system root.
|
|
link := filepath.Join(priv, "escape")
|
|
if err := os.Symlink("/etc", link); err != nil {
|
|
t.Skipf("symlink unsupported: %v", err)
|
|
}
|
|
if _, err := sess.resolve("/me/escape/passwd"); err == nil {
|
|
t.Error("path through an escaping symlink must be rejected")
|
|
}
|
|
}
|
|
|
|
func TestOwnPublicWritable(t *testing.T) {
|
|
svc, _, u := newTestService(t)
|
|
|
|
// A member's /public is their own area (served at ~name/public) — they can
|
|
// always write to it, and it resolves under <root>/public/<name>.
|
|
sess, _ := svc.newSession(u)
|
|
if err := sess.Filecmd(sftp.NewRequest("Mkdir", "/public/uploads")); err != nil {
|
|
t.Fatalf("own /public mkdir should succeed: %v", err)
|
|
}
|
|
res, err := sess.resolve("/public/uploads")
|
|
if err != nil || !res.writable {
|
|
t.Fatalf("own /public should resolve writable: %+v err=%v", res, err)
|
|
}
|
|
if want := svc.userPub(u.Name); !within(want, res.real) && res.real != want {
|
|
t.Errorf("/public resolved to %q, want under %q", res.real, want)
|
|
}
|
|
}
|
|
|
|
func TestQuotaEnforced(t *testing.T) {
|
|
svc, _, u := newTestService(t)
|
|
sess, _ := svc.newSession(u)
|
|
sess.quota = 100 // tiny
|
|
sess.used.Store(0) // isolate the writer from the seeded-README baseline
|
|
|
|
f, err := os.Create(filepath.Join(svc.privRoot(u.Name), "big"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer f.Close()
|
|
w := "aWriter{f: f, sess: sess}
|
|
|
|
if _, err := w.WriteAt(make([]byte, 80), 0); err != nil {
|
|
t.Fatalf("write within quota failed: %v", err)
|
|
}
|
|
if _, err := w.WriteAt(make([]byte, 80), 80); err != sftp.ErrSSHFxFailure {
|
|
t.Errorf("write over quota should fail, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestWebSaveOverQuotaPreservesExistingFile(t *testing.T) {
|
|
svc, _, u := newTestService(t)
|
|
sess, _ := svc.newSession(u)
|
|
sess.quota = 5
|
|
sess.used.Store(0) // isolate the writer from the seeded-README baseline
|
|
|
|
if _, err := sess.webSave("/me/note.txt", strings.NewReader("ok")); err != nil {
|
|
t.Fatalf("initial save failed: %v", err)
|
|
}
|
|
if _, err := sess.webSave("/me/note.txt", strings.NewReader("too-large")); err != errQuota {
|
|
t.Fatalf("over-quota replace: got %v, want errQuota", err)
|
|
}
|
|
|
|
got, err := os.ReadFile(filepath.Join(svc.privRoot(u.Name), "note.txt"))
|
|
if err != nil {
|
|
t.Fatalf("existing file should remain after failed replace: %v", err)
|
|
}
|
|
if string(got) != "ok" {
|
|
t.Fatalf("existing file changed after failed replace: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestUsage(t *testing.T) {
|
|
svc, _, u := newTestService(t)
|
|
if err := svc.ensureWorkspace(u.Name); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := []byte(strings.Repeat("x", 512))
|
|
if err := os.WriteFile(filepath.Join(svc.privRoot(u.Name), "a.txt"), want, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
usage, err := svc.Usage(u)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if usage.Bytes != 512 {
|
|
t.Errorf("usage = %d, want 512", usage.Bytes)
|
|
}
|
|
if usage.Quota != 1<<20 {
|
|
t.Errorf("quota = %d, want default", usage.Quota)
|
|
}
|
|
}
|
|
|
|
func TestUsageCountsOwnedAreas(t *testing.T) {
|
|
svc, _, u := newTestService(t)
|
|
if err := svc.ensureWorkspace(u.Name); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := svc.ensureUserPub(u.Name); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// ensureUserPub seeds a default README.txt into the public area; it counts
|
|
// toward the gauge like any other public file.
|
|
seed := int64(len(defaultPublicReadme))
|
|
// Both of the member's owned areas — private /me and their public /public
|
|
// (<root>/public/<name>) — count toward the quota gauge. Another member's
|
|
// public area does not.
|
|
if err := os.WriteFile(filepath.Join(svc.privRoot(u.Name), "a.txt"), []byte(strings.Repeat("x", 512)), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(svc.userPub(u.Name), "b.txt"), []byte(strings.Repeat("y", 256)), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(svc.userPub("someoneelse"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(svc.userPub("someoneelse"), "x.txt"), []byte(strings.Repeat("z", 9999)), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
usage, err := svc.Usage(u)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if want := int64(768) + seed; usage.Bytes != want {
|
|
t.Errorf("usage = %d, want %d (512 /me + 256 /public + seeded README, other members excluded)", usage.Bytes, want)
|
|
}
|
|
}
|
|
|
|
func TestSafeJoinSymlinkedRoot(t *testing.T) {
|
|
// A storage root reached through a symlink must not cause valid child paths
|
|
// to be rejected. Before the fix, EvalSymlinks resolved the probe to the
|
|
// canonical path while within() still compared against the lexical root,
|
|
// producing a false-positive errEscape.
|
|
dir := t.TempDir()
|
|
realRoot := filepath.Join(dir, "real")
|
|
if err := os.MkdirAll(realRoot, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
linkRoot := filepath.Join(dir, "link")
|
|
if err := os.Symlink(realRoot, linkRoot); err != nil {
|
|
t.Skipf("symlink unsupported: %v", err)
|
|
}
|
|
|
|
// A normal file inside the real root, accessed via the symlinked root.
|
|
target := filepath.Join(realRoot, "notes.txt")
|
|
if err := os.WriteFile(target, []byte("hello"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := safeJoin(linkRoot, "notes.txt")
|
|
if err != nil {
|
|
t.Fatalf("safeJoin with symlinked root rejected valid path: %v", err)
|
|
}
|
|
if want := filepath.Join(linkRoot, "notes.txt"); got != want {
|
|
t.Errorf("safeJoin = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestSafeJoinChildSymlinkEscapeStillBlocked(t *testing.T) {
|
|
// A child symlink inside the area that points outside must still be
|
|
// rejected, even when the root itself is a symlink.
|
|
dir := t.TempDir()
|
|
realRoot := filepath.Join(dir, "real")
|
|
if err := os.MkdirAll(realRoot, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
linkRoot := filepath.Join(dir, "link")
|
|
if err := os.Symlink(realRoot, linkRoot); err != nil {
|
|
t.Skipf("symlink unsupported: %v", err)
|
|
}
|
|
|
|
// Plant an escaping symlink inside the (real) workspace.
|
|
escape := filepath.Join(realRoot, "escape")
|
|
if err := os.Symlink("/etc", escape); err != nil {
|
|
t.Skipf("symlink unsupported: %v", err)
|
|
}
|
|
if _, err := safeJoin(linkRoot, "escape/passwd"); err == nil {
|
|
t.Error("path through an escaping child symlink must be rejected")
|
|
}
|
|
}
|
|
|
|
func TestRevokeBlocksAndQuotaOverride(t *testing.T) {
|
|
svc, st, u := newTestService(t)
|
|
if err := st.SetFilesQuota(u.ID, 4096); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := svc.quotaFor(u.ID); got != 4096 {
|
|
t.Errorf("quotaFor = %d, want 4096 override", got)
|
|
}
|
|
if err := st.SetFilesRevoked(u.ID, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fa, err := st.FilesAccess(u.ID)
|
|
if err != nil || !fa.Revoked {
|
|
t.Errorf("FilesAccess revoked = %+v err=%v", fa, err)
|
|
}
|
|
}
|