From a5916f6177632c79ab91098e48e7556f6592b3f9 Mon Sep 17 00:00:00 2001 From: mptyl Date: Tue, 11 Aug 2026 14:10:16 +0200 Subject: [PATCH] fix: harden native workspace root and docker gates --- backend/native/workspace-fs-at/binding.gyp | 10 +- .../native/workspace-fs-at/workspace_fs_at.cc | 118 +++++++++++++++--- backend/package.json | 5 +- backend/scripts/build-workspace-fs-at.mjs | 10 +- .../src/native/workspace-fs-at-binding.d.ts | 10 +- backend/src/workspaces/preprocessing-state.ts | 17 +-- backend/src/workspaces/workspace-fs-at.ts | 82 +++++++++--- .../workspaces/workspace-lock-root-lease.ts | 104 ++++++++++----- .../fixtures/workspace-fs-at-race-worker.mjs | 6 +- .../fixtures/workspace-lock-root-worker.mjs | 15 ++- .../workspace-registry-addressed-worker.mjs | 2 +- .../workspace-session-readers-worker.mjs | 15 ++- backend/test/workspace-fs-at-native.test.ts | 41 +++++- .../test/workspace-lock-root-lease.test.ts | 32 ++++- .../workspace-session-readers-lock.test.ts | 19 ++- docker/core.Dockerfile | 11 +- 16 files changed, 401 insertions(+), 96 deletions(-) diff --git a/backend/native/workspace-fs-at/binding.gyp b/backend/native/workspace-fs-at/binding.gyp index f98bfb8a..16a7f746 100644 --- a/backend/native/workspace-fs-at/binding.gyp +++ b/backend/native/workspace-fs-at/binding.gyp @@ -1 +1,9 @@ -{ "targets": [{ "target_name": "workspace_fs_at", "sources": ["workspace_fs_at.cc"], "cflags_cc": ["-std=c++17"], "defines": ["NAPI_VERSION=8"] }] } \ No newline at end of file +{ + "targets": [{ + "target_name": "workspace_fs_at", + "sources": ["workspace_fs_at.cc"], + "cflags_cc": ["-std=c++17"], + "defines": ["NAPI_VERSION=8"], + "conditions": [["OS=='linux' or OS=='mac'", {}], ["OS=='win'", {"type": "none"}]] + }] +} diff --git a/backend/native/workspace-fs-at/workspace_fs_at.cc b/backend/native/workspace-fs-at/workspace_fs_at.cc index b537fcfe..d0b644a2 100644 --- a/backend/native/workspace-fs-at/workspace_fs_at.cc +++ b/backend/native/workspace-fs-at/workspace_fs_at.cc @@ -7,24 +7,108 @@ #include #include #include +#include #ifdef __APPLE__ #include +#ifndef F_FULLFSYNC +#define F_FULLFSYNC 51 #endif -struct Handle { uint64_t magic; int fd; bool directory; bool closed; }; +#endif + +namespace { static const uint64_t MAGIC=0x5448545746534154ULL; -static std::unordered_set handles; -static void finalize(napi_env env, void* data, void*) { Handle* h=(Handle*)data; if(h && handles.find(h)!=handles.end() && h->magic==MAGIC && !h->closed){::close(h->fd);h->closed=true;} handles.erase(h); delete h; } -static bool getHandle(napi_env env,napi_value v,Handle** out){ void* p=nullptr; if(napi_get_value_external(env,v,&p)!=napi_ok||!p||handles.find(p)==handles.end())return false; auto*h=(Handle*)p; if(h->magic!=MAGIC||h->closed)return false;*out=h;return true; } -static const char* errnoName(int e){switch(e){case EINVAL:return "EINVAL";case EBADF:return "EBADF";case EEXIST:return "EEXIST";case ENOENT:return "ENOENT";case ENOTDIR:return "ENOTDIR";case ELOOP:return "ELOOP";case EACCES:return "EACCES";case EPERM:return "EPERM";case EAGAIN:return "EAGAIN";default:return "EIO";}} -static napi_value error(napi_env env,const char* syscall,int e){ napi_value msg,err,code,sys; napi_create_string_utf8(env,strerror(e),NAPI_AUTO_LENGTH,&msg); napi_create_error(env,nullptr,msg,&err); napi_create_string_utf8(env,syscall,NAPI_AUTO_LENGTH,&sys); napi_set_named_property(env,err,"syscall",sys); napi_create_string_utf8(env,errnoName(e),NAPI_AUTO_LENGTH,&code); napi_set_named_property(env,err,"code",code); napi_value en; napi_create_int32(env,e,&en); napi_set_named_property(env,err,"errno",en); return err; } -static napi_value fail(napi_env env,const char*s,int e){ napi_value x=error(env,s,e); napi_throw(env,x); return nullptr; } -static bool str(napi_env env,napi_value v,std::string& out){ size_t n; if(napi_get_value_string_utf8(env,v,nullptr,0,&n)!=napi_ok)return false; out.resize(n); napi_get_value_string_utf8(env,v,out.data(),n+1,&n);return true; } -static napi_value statObj(napi_env env,const struct stat& st){ napi_value o,n; napi_create_object(env,&o); napi_create_bigint_uint64(env,(uint64_t)st.st_dev,&n); napi_set_named_property(env,o,"device",n); napi_create_bigint_uint64(env,(uint64_t)st.st_ino,&n); napi_set_named_property(env,o,"inode",n); napi_create_uint32(env,(uint32_t)st.st_mode,&n); napi_set_named_property(env,o,"mode",n); napi_create_uint32(env,(uint32_t)st.st_uid,&n); napi_set_named_property(env,o,"uid",n); napi_create_uint32(env,(uint32_t)st.st_gid,&n); napi_set_named_property(env,o,"gid",n); napi_create_bigint_uint64(env,(uint64_t)st.st_nlink,&n); napi_set_named_property(env,o,"nlink",n); return o; } -static napi_value result(napi_env env,int fd,bool dir,const struct stat&st){ auto*h=new Handle{MAGIC,fd,dir,false}; handles.insert(h); napi_value e,o,s; napi_create_external(env,h,finalize,nullptr,&e); napi_create_object(env,&o); napi_set_named_property(env,o,"handle",e); s=statObj(env,st); napi_set_named_property(env,o,"openedStat",s); return o; } -static napi_value openatFn(napi_env env,napi_callback_info info){ size_t argc=1; napi_value a[1]; if(napi_get_cb_info(env,info,&argc,a,nullptr,nullptr)!=napi_ok||argc!=1)return fail(env,"openat",EINVAL); napi_value pv,nv,kv,mv; if(napi_get_named_property(env,a[0],"parent",&pv)!=napi_ok||napi_get_named_property(env,a[0],"name",&nv)!=napi_ok||napi_get_named_property(env,a[0],"kind",&kv)!=napi_ok||napi_get_named_property(env,a[0],"createMode",&mv)!=napi_ok)return fail(env,"openat",EINVAL); std::string name,kind; if(!str(env,nv,name)||!str(env,kv,kind))return fail(env,"openat",EINVAL); int32_t mode; if(napi_get_value_int32(env,mv,&mode)!=napi_ok)return fail(env,"openat",EINVAL); bool root=name=="/"; if(!root&&(name.empty()||name.size()>255||name=="."||name==".."||name.find('/')!=std::string::npos||name.find('\0')!=std::string::npos))return fail(env,"openat",EINVAL); Handle*ph=nullptr; if(root){if(kind!="directory"||mode!=0)return fail(env,"openat",EINVAL);} else if(!getHandle(env,pv,&ph)||!ph->directory)return fail(env,"openat",EBADF); int fd; if(root)fd=::open("/",O_RDONLY|O_DIRECTORY|O_CLOEXEC|O_NOFOLLOW); else if(kind=="directory"&&mode==0)fd=::openat(ph->fd,name.c_str(),O_RDONLY|O_DIRECTORY|O_CLOEXEC|O_NOFOLLOW); else if(kind=="regular_lock"&&mode==0600)fd=::openat(ph->fd,name.c_str(),O_RDWR|O_CREAT|O_CLOEXEC|O_NOFOLLOW,0600); else return fail(env,"openat",EINVAL); if(fd<0)return fail(env,"openat",errno); struct stat st; if(::fstat(fd,&st)<0){int e=errno;::close(fd);return fail(env,"fstat",e);} if(kind=="directory"&&!S_ISDIR(st.st_mode)){::close(fd);return fail(env,"openat",ENOTDIR);} if(kind=="regular_lock"&&(!S_ISREG(st.st_mode)||(st.st_mode&0777)!=0600)){::close(fd);return fail(env,"openat",EPERM);} return result(env,fd,kind=="directory",st); } -static napi_value mkdiratFn(napi_env env,napi_callback_info info){size_t n=3; napi_value a[3]; napi_get_cb_info(env,info,&n,a,nullptr,nullptr);Handle*h;std::string s;int32_t m;if(n!=3||!getHandle(env,a[0],&h)||!h->directory||!str(env,a[1],s)||napi_get_value_int32(env,a[2],&m)!=napi_ok||m!=0700)return fail(env,"mkdirat",EINVAL);if(s.empty()||s.size()>255||s=="."||s==".."||s.find('/')!=std::string::npos||s.find('\0')!=std::string::npos)return fail(env,"mkdirat",EINVAL);if(::mkdirat(h->fd,s.c_str(),0700)<0)return fail(env,"mkdirat",errno);return nullptr;} -static napi_value fstatatFn(napi_env env,napi_callback_info info){size_t n=2;napi_value a[2];napi_get_cb_info(env,info,&n,a,nullptr,nullptr);Handle*h;std::string s;if(n!=2||!getHandle(env,a[0],&h)||!h->directory||!str(env,a[1],s)||s.empty()||s.size()>255||s=="."||s==".."||s.find('/')!=std::string::npos||s.find('\0')!=std::string::npos)return fail(env,"fstatat",EINVAL);struct stat st;if(::fstatat(h->fd,s.c_str(),&st,AT_SYMLINK_NOFOLLOW)<0)return fail(env,"fstatat",errno);return statObj(env,st);} -static napi_value fsyncFn(napi_env env,napi_callback_info info){size_t n=1;napi_value a[1];napi_get_cb_info(env,info,&n,a,nullptr,nullptr);Handle*h;if(n!=1||!getHandle(env,a[0],&h)||!h->directory)return fail(env,"fsync",EBADF);if(::fsync(h->fd)<0)return fail(env,"fsync",errno);return nullptr;} -static napi_value closeFn(napi_env env,napi_callback_info info){size_t n=1;napi_value a[1];napi_get_cb_info(env,info,&n,a,nullptr,nullptr);Handle*h;if(n!=1||!getHandle(env,a[0],&h))return fail(env,"close",EBADF);h->closed=true;int rc=::close(h->fd);if(rc<0)return fail(env,"close",errno);return nullptr;} -static napi_value fdFn(napi_env env,napi_callback_info info){size_t n=1;napi_value a[1];napi_get_cb_info(env,info,&n,a,nullptr,nullptr);Handle*h;if(n!=1||!getHandle(env,a[0],&h))return fail(env,"fcntl",EBADF);napi_value x;napi_create_int32(env,h->fd,&x);return x;} -static napi_value init(napi_env env,napi_value exports){napi_property_descriptor p[]={{"openat",0,openatFn,0,0,0,napi_enumerable,0},{"mkdirat",0,mkdiratFn,0,0,0,napi_enumerable,0},{"fstatat",0,fstatatFn,0,0,0,napi_enumerable,0},{"fsyncDirectory",0,fsyncFn,0,0,0,napi_enumerable,0},{"close",0,closeFn,0,0,0,napi_enumerable,0},{"fdNumberForSynchronousBorrow",0,fdFn,0,0,0,napi_enumerable,0}};napi_define_properties(env,exports,6,p);return exports;} NAPI_MODULE(NODE_GYP_MODULE_NAME,init) +static const char OWNER_TOKEN = 0; +struct Handle { uint64_t magic; const void* owner; int fd; bool directory; bool closed; unsigned borrows; }; +static std::unordered_set handles; +static void finalize(napi_env, void* data, void*) { + auto *h=static_cast(data); + if (!h) return; + if (handles.erase(h) && !h->closed) { h->closed=true; int rc = ::close(h->fd); (void)rc; } + delete h; +} +static bool getHandle(napi_env env,napi_value v,Handle** out) { + void *p=nullptr; + if (napi_get_value_external(env,v,&p)!=napi_ok || !p) return false; + auto *h=static_cast(p); + if (handles.find(h)==handles.end() || h->magic!=MAGIC || h->owner!=&OWNER_TOKEN || h->closed) return false; + *out=h; return true; +} +static const char* errnoName(int e) { + switch(e) { + case EINVAL:return "EINVAL"; case EBADF:return "EBADF"; case EEXIST:return "EEXIST"; + case ENOENT:return "ENOENT"; case ENOTDIR:return "ENOTDIR"; case ELOOP:return "ELOOP"; + case EACCES:return "EACCES"; case EPERM:return "EPERM"; case EAGAIN:return "EAGAIN"; + case EBUSY:return "EBUSY"; case ENOSPC:return "ENOSPC"; case EDQUOT:return "EDQUOT"; + case ENAMETOOLONG:return "ENAMETOOLONG"; case EROFS:return "EROFS"; case EISDIR:return "EISDIR"; + case ENFILE:return "ENFILE"; case EMFILE:return "EMFILE"; case ENOMEM:return "ENOMEM"; + case EIO:return "EIO"; case EINTR:return "EINTR"; case ENXIO:return "ENXIO"; + default: return nullptr; + } +} +static napi_value error(napi_env env,const char* syscall,int e,const char* forced=nullptr) { + napi_value msg,err,code,sys; + const char *name=forced?forced:errnoName(e); + std::string fallback; + if (!name) { fallback="ERR_WORKSPACE_FS_AT_ERRNO_"+std::to_string(e); name=fallback.c_str(); } + napi_create_string_utf8(env,forced?forced:strerror(e),NAPI_AUTO_LENGTH,&msg); + napi_create_error(env,nullptr,msg,&err); + napi_create_string_utf8(env,syscall,NAPI_AUTO_LENGTH,&sys); napi_set_named_property(env,err,"syscall",sys); + napi_create_string_utf8(env,name,NAPI_AUTO_LENGTH,&code); napi_set_named_property(env,err,"code",code); + napi_value en; napi_create_int32(env,e,&en); napi_set_named_property(env,err,"errno",en); + return err; +} +static napi_value fail(napi_env env,const char*s,int e,const char* forced=nullptr){ napi_value x=error(env,s,e,forced); napi_throw(env,x); return nullptr; } +static bool str(napi_env env,napi_value v,std::string& out) { + size_t n=0; if(napi_get_value_string_utf8(env,v,nullptr,0,&n)!=napi_ok) return false; + out.resize(n); size_t got=0; if(napi_get_value_string_utf8(env,v,out.data(),n+1,&got)!=napi_ok) return false; out.resize(got); return true; +} +static bool validComponent(const std::string& s) { + return !s.empty() && s.size()<=255 && s!="." && s!=".." && s.find('/')==std::string::npos && s.find('\0')==std::string::npos; +} +static int openRetry(int dir,const char *name,int flags,mode_t mode) { int fd; do {fd=dir<0 ? ::open(name,flags,mode) : ::openat(dir,name,flags,mode);} while(fd<0&&errno==EINTR); return fd; } +static int statRetry(int fd, struct stat *st) { int rc; do {rc=::fstat(fd,st);} while(rc<0&&errno==EINTR); return rc; } +static napi_value statObj(napi_env env,const struct stat& st) { + napi_value o,n; napi_create_object(env,&o); + napi_create_bigint_uint64(env,(uint64_t)st.st_dev,&n); napi_set_named_property(env,o,"device",n); + napi_create_bigint_uint64(env,(uint64_t)st.st_ino,&n); napi_set_named_property(env,o,"inode",n); + napi_create_uint32(env,(uint32_t)st.st_mode,&n); napi_set_named_property(env,o,"mode",n); + napi_create_uint32(env,(uint32_t)st.st_uid,&n); napi_set_named_property(env,o,"uid",n); + napi_create_uint32(env,(uint32_t)st.st_gid,&n); napi_set_named_property(env,o,"gid",n); + napi_create_bigint_uint64(env,(uint64_t)st.st_nlink,&n); napi_set_named_property(env,o,"nlink",n); return o; +} +static napi_value result(napi_env env,int fd,bool dir,const struct stat&st) { + auto*h=new Handle{MAGIC,&OWNER_TOKEN,fd,dir,false,0}; handles.insert(h); + napi_value e,o,s; napi_create_external(env,h,finalize,nullptr,&e); napi_create_object(env,&o); + napi_set_named_property(env,o,"handle",e); s=statObj(env,st); napi_set_named_property(env,o,"openedStat",s); return o; +} +static bool getInput(napi_env env,napi_callback_info info,napi_value *a,size_t *argc) { return napi_get_cb_info(env,info,argc,a,nullptr,nullptr)==napi_ok; } +static napi_value openatFn(napi_env env,napi_callback_info info) { + size_t argc=1; napi_value a[1]; if(!getInput(env,info,a,&argc)||argc!=1)return fail(env,"openat",EINVAL); + napi_value pv,nv,kv,mv; if(napi_get_named_property(env,a[0],"parent",&pv)!=napi_ok||napi_get_named_property(env,a[0],"name",&nv)!=napi_ok||napi_get_named_property(env,a[0],"kind",&kv)!=napi_ok||napi_get_named_property(env,a[0],"createMode",&mv)!=napi_ok)return fail(env,"openat",EINVAL); + std::string name,kind; if(!str(env,nv,name)||!str(env,kv,kind))return fail(env,"openat",EINVAL); int32_t mode;if(napi_get_value_int32(env,mv,&mode)!=napi_ok)return fail(env,"openat",EINVAL); + bool root=name=="/"; if(!root&&!validComponent(name))return fail(env,"openat",EINVAL); Handle*ph=nullptr; + if(root){if(kind!="directory"||mode!=0)return fail(env,"openat",EINVAL);} else if(!getHandle(env,pv,&ph)||!ph->directory)return fail(env,"openat",EBADF); + int fd=-1; + if(root) fd=openRetry(-1,"/",O_RDONLY|O_DIRECTORY|O_CLOEXEC|O_NOFOLLOW,0); + else if(kind=="directory"&&mode==0) { fd=openRetry(ph->fd,name.c_str(),O_RDONLY|O_DIRECTORY|O_CLOEXEC|O_NOFOLLOW,0); } + else if(kind=="regular_lock"&&mode==0600) fd=openRetry(ph->fd,name.c_str(),O_RDWR|O_CREAT|O_CLOEXEC|O_NOFOLLOW,0600); + else return fail(env,"openat",EINVAL); + if(fd<0)return fail(env,"openat",errno); + struct stat st; if(statRetry(fd,&st)<0){int e=errno;::close(fd);return fail(env,"fstat",e);} + if(kind=="directory"&&!S_ISDIR(st.st_mode)){::close(fd);return fail(env,"openat",ENOTDIR);} + if(kind=="regular_lock" && (!S_ISREG(st.st_mode)||(st.st_mode&0777)!=0600||st.st_uid!=(uid_t)::geteuid()||st.st_nlink!=1)) {::close(fd);return fail(env,"openat",EPERM);} + return result(env,fd,kind=="directory",st); +} +static napi_value mkdiratFn(napi_env env,napi_callback_info info){size_t n=3;napi_value a[3];napi_get_cb_info(env,info,&n,a,nullptr,nullptr);Handle*h;std::string s;int32_t m;if(n!=3||!getHandle(env,a[0],&h)||!h->directory||!str(env,a[1],s)||napi_get_value_int32(env,a[2],&m)!=napi_ok||m!=0700||!validComponent(s))return fail(env,"mkdirat",EINVAL);int rc;do{rc=::mkdirat(h->fd,s.c_str(),0700);}while(rc<0&&errno==EINTR);if(rc<0)return fail(env,"mkdirat",errno);return nullptr;} +static napi_value fstatatFn(napi_env env,napi_callback_info info){size_t n=2;napi_value a[2];napi_get_cb_info(env,info,&n,a,nullptr,nullptr);Handle*h;std::string s;if(n!=2||!getHandle(env,a[0],&h)||!h->directory||!str(env,a[1],s)||!validComponent(s))return fail(env,"fstatat",EINVAL);struct stat st;int rc;do{rc=::fstatat(h->fd,s.c_str(),&st,AT_SYMLINK_NOFOLLOW);}while(rc<0&&errno==EINTR);if(rc<0)return fail(env,"fstatat",errno);return statObj(env,st);} +static napi_value fsyncFn(napi_env env,napi_callback_info info){size_t n=1;napi_value a[1];napi_get_cb_info(env,info,&n,a,nullptr,nullptr);Handle*h;if(n!=1||!getHandle(env,a[0],&h)||!h->directory)return fail(env,"fsync",EBADF);int rc;do{rc=::fsync(h->fd);}while(rc<0&&errno==EINTR); +#ifdef __APPLE__ + if(rc<0&&(errno==EINVAL||errno==ENOTSUP)){int frc;do{frc=::fcntl(h->fd,F_FULLFSYNC);}while(frc<0&&errno==EINTR);if(frc==0)return nullptr;rc=frc;} +#endif + if(rc<0)return fail(env,"fsync",errno);return nullptr;} +static napi_value closeFn(napi_env env,napi_callback_info info){size_t n=1;napi_value a[1];napi_get_cb_info(env,info,&n,a,nullptr,nullptr);Handle*h;if(n!=1||!getHandle(env,a[0],&h))return fail(env,"close",EBADF);if(h->borrows)return fail(env,"close",EBUSY);h->closed=true;handles.erase(h);int rc=::close(h->fd);if(rc<0){int e=errno;if(e==EINTR)return fail(env,"close",e,"ERR_WORKSPACE_FS_AT_CLOSE_UNCERTAIN");return fail(env,"close",e);}return nullptr;} +static napi_value withFdFn(napi_env env,napi_callback_info info){size_t n=2;napi_value a[2];napi_get_cb_info(env,info,&n,a,nullptr,nullptr);Handle*h; napi_valuetype t;if(n!=2||!getHandle(env,a[0],&h)||napi_typeof(env,a[1],&t)!=napi_ok||t!=napi_function)return fail(env,"borrow",EINVAL);h->borrows++;napi_value argv; napi_create_int32(env,h->fd,&argv); napi_value out; napi_value global; napi_get_global(env, &global); napi_status rc=napi_call_function(env, global,a[1],1,&argv,&out);h->borrows--;if(rc!=napi_ok)return nullptr;return out;} +static napi_value duplicateForChildStdioFn(napi_env env,napi_callback_info info){size_t n=4;napi_value a[4];napi_get_cb_info(env,info,&n,a,nullptr,nullptr);Handle*w,*r;int32_t wf,rf;if(n!=4||!getHandle(env,a[0],&w)||!getHandle(env,a[1],&r)||w->directory||!r->directory||napi_get_value_int32(env,a[2],&wf)!=napi_ok||napi_get_value_int32(env,a[3],&rf)!=napi_ok||wf<0||rf<0)return fail(env,"dup2",EINVAL);if(w->borrows||r->borrows)return fail(env,"dup2",EBUSY);int rc;do{rc=::dup2(w->fd,wf);}while(rc<0&&errno==EINTR);if(rc<0)return fail(env,"dup2",errno);do{rc=::dup2(r->fd,rf);}while(rc<0&&errno==EINTR);if(rc<0)return fail(env,"dup2",errno);return nullptr;} +static napi_value init(napi_env env,napi_value exports){napi_property_descriptor pub[]={{"openat",0,openatFn,0,0,0,napi_enumerable,0},{"mkdirat",0,mkdiratFn,0,0,0,napi_enumerable,0},{"fstatat",0,fstatatFn,0,0,0,napi_enumerable,0},{"fsyncDirectory",0,fsyncFn,0,0,0,napi_enumerable,0},{"close",0,closeFn,0,0,0,napi_enumerable,0}};napi_define_properties(env,exports,5,pub);napi_property_descriptor priv[]={{"withFd",0,withFdFn,0,0,0,napi_default,0},{"duplicateForChildStdio",0,duplicateForChildStdioFn,0,0,0,napi_default,0}};napi_define_properties(env,exports,2,priv);return exports;} +} +NAPI_MODULE(NODE_GYP_MODULE_NAME,init) diff --git a/backend/package.json b/backend/package.json index 88bf7535..83766fdb 100644 --- a/backend/package.json +++ b/backend/package.json @@ -5,11 +5,12 @@ "scripts": { "dev": "tsx watch src/server.ts", "prebuild": "node scripts/clean-dist.mjs", - "build": "tsc -p tsconfig.json", + "build": "npm run build:ts", "test": "vitest run", "start": "node dist/server.js", "build:native": "node scripts/build-workspace-fs-at.mjs", - "postinstall": "npm run build:native" + "postinstall": "npm run build:native", + "build:ts": "node scripts/clean-dist.mjs && tsc -p tsconfig.json" }, "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/backend/scripts/build-workspace-fs-at.mjs b/backend/scripts/build-workspace-fs-at.mjs index c90c7154..f5efd59a 100644 --- a/backend/scripts/build-workspace-fs-at.mjs +++ b/backend/scripts/build-workspace-fs-at.mjs @@ -1,8 +1,16 @@ +import { existsSync, readdirSync } from "node:fs"; import { spawnSync } from "node:child_process"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +const major = Number(process.versions.node.split(".")[0]); +if (major !== 22 || Number(process.versions.napi ?? 0) < 8) throw new Error(`workspace-fs-at requires Node 22 / N-API 8 (got ${process.versions.node} / N-API ${process.versions.napi})`); +if (process.platform !== "linux" && process.platform !== "darwin") throw new Error(`workspace-fs-at unsupported platform: ${process.platform}`); const root = resolve(dirname(fileURLToPath(import.meta.url)), "../native/workspace-fs-at"); const npm = process.platform === "win32" ? "npm.cmd" : "npm"; -const result = spawnSync(npm, ["exec", "--", "node-gyp@11.2.0", "rebuild", "--offline"], { cwd: root, stdio: "inherit" }); +const result = spawnSync(npm, ["exec", "--offline", "--", "node-gyp@11.2.0", "rebuild", "--offline"], { cwd: root, stdio: "inherit" }); if (result.error) throw result.error; if (result.status !== 0) process.exit(result.status ?? 1); +const output = resolve(root, "build/Release/workspace_fs_at.node"); +if (!existsSync(output)) throw new Error(`native addon output missing: ${output}`); +const outputs = readdirSync(resolve(root, "build/Release")).filter(name => name.endsWith(".node")); +if (outputs.length !== 1 || outputs[0] !== "workspace_fs_at.node") throw new Error(`unexpected native outputs: ${outputs.join(",")}`); diff --git a/backend/src/native/workspace-fs-at-binding.d.ts b/backend/src/native/workspace-fs-at-binding.d.ts index e3699bcc..4da7cc20 100644 --- a/backend/src/native/workspace-fs-at-binding.d.ts +++ b/backend/src/native/workspace-fs-at-binding.d.ts @@ -3,6 +3,12 @@ declare const nativeWorkspaceFsAtComponentBrand: unique symbol; export interface NativeWorkspaceFsAtHandleV1 { readonly [nativeWorkspaceFsAtHandleBrand]: true; } export type NativeWorkspaceFsAtComponentV1 = string & { readonly [nativeWorkspaceFsAtComponentBrand]: true }; export interface NativeWorkspaceFsAtStatV1 { readonly device: bigint; readonly inode: bigint; readonly mode: number; readonly uid: number; readonly gid: number; readonly nlink: bigint; } -export interface NativeWorkspaceFsAtErrorV1 extends Error { readonly code:string; readonly errno:number; readonly syscall:"openat"|"mkdirat"|"fstat"|"fstatat"|"fsync"|"fcntl"|"close"; } +export interface NativeWorkspaceFsAtErrorV1 extends Error { readonly code:string; readonly errno:number; readonly syscall:"openat"|"mkdirat"|"fstat"|"fstatat"|"fsync"|"borrow"|"dup2"|"close"; } export interface NativeWorkspaceFsAtOpenResultV1 { readonly handle: NativeWorkspaceFsAtHandleV1; readonly openedStat: NativeWorkspaceFsAtStatV1; } -export interface WorkspaceFsAtBindingV1 { openat(input: { readonly parent: NativeWorkspaceFsAtHandleV1|null; readonly name: "/"|NativeWorkspaceFsAtComponentV1; readonly kind: "directory"|"regular_lock"; readonly createMode: 0|0o600 }): NativeWorkspaceFsAtOpenResultV1; mkdirat(parent: NativeWorkspaceFsAtHandleV1,name:NativeWorkspaceFsAtComponentV1,mode:0o700):void; fstatat(parent:NativeWorkspaceFsAtHandleV1,name:NativeWorkspaceFsAtComponentV1):NativeWorkspaceFsAtStatV1; fsyncDirectory(handle:NativeWorkspaceFsAtHandleV1):void; close(handle:NativeWorkspaceFsAtHandleV1):void; fdNumberForSynchronousBorrow(handle:NativeWorkspaceFsAtHandleV1):number; } +export interface WorkspaceFsAtBindingV1 { + openat(input: { readonly parent: NativeWorkspaceFsAtHandleV1|null; readonly name: "/"|NativeWorkspaceFsAtComponentV1; readonly kind: "directory"|"regular_lock"; readonly createMode: 0|0o600 }): NativeWorkspaceFsAtOpenResultV1; + mkdirat(parent: NativeWorkspaceFsAtHandleV1,name:NativeWorkspaceFsAtComponentV1,mode:0o700):void; + fstatat(parent:NativeWorkspaceFsAtHandleV1,name:NativeWorkspaceFsAtComponentV1):NativeWorkspaceFsAtStatV1; + fsyncDirectory(handle:NativeWorkspaceFsAtHandleV1):void; + close(handle:NativeWorkspaceFsAtHandleV1):void; +} diff --git a/backend/src/workspaces/preprocessing-state.ts b/backend/src/workspaces/preprocessing-state.ts index 8fd6e425..162693e2 100644 --- a/backend/src/workspaces/preprocessing-state.ts +++ b/backend/src/workspaces/preprocessing-state.ts @@ -108,18 +108,20 @@ export class BorrowedWorkspaceSessionReadersExclusiveLockLease { } export class WorkspaceWriterLockCapability { private live = true; private readerExclusive = false; - private constructor(readonly workspaceId: CanonicalWorkspaceId, readonly rootIdentity: WorkspaceLockRootIdentityV1, private readonly root: VerifiedWorkspaceLockRootLease, private readonly fs: WorkspaceFsAtV1, private readonly writer: OwnedWorkspaceFsAtRegularFile) {} - static make(id: CanonicalWorkspaceId, identity: WorkspaceLockRootIdentityV1, root: VerifiedWorkspaceLockRootLease, fs: WorkspaceFsAtV1, writer: OwnedWorkspaceFsAtRegularFile) { return new WorkspaceWriterLockCapability(id, identity, root, fs, writer); } + private constructor(readonly workspaceId: CanonicalWorkspaceId, readonly rootIdentity: WorkspaceLockRootIdentityV1, private readonly root: VerifiedWorkspaceLockRootLease, private readonly writer: import("./workspace-lock-root-lease.js").WorkspaceRootLock) {} + static create(id: CanonicalWorkspaceId, identity: WorkspaceLockRootIdentityV1, root: VerifiedWorkspaceLockRootLease, writer: import("./workspace-lock-root-lease.js").WorkspaceRootLock): WorkspaceWriterLockCapability { return new WorkspaceWriterLockCapability(id, identity, root, writer); } private check(): void { if (!this.live) throw fail(); } async runUnderSessionReadersExclusive(action: (lease: BorrowedWorkspaceSessionReadersExclusiveLockLease) => Promise): Promise { this.check(); if (this.readerExclusive) throw fail(); this.readerExclusive = true; - let lock: OwnedWorkspaceFsAtRegularFile | undefined; let borrowed: BorrowedWorkspaceSessionReadersExclusiveLockLease | undefined; let result!: T; - try { result = await this.root._withRoot(async (dir, fs) => { lock = fs.openOrCreateLockAt(dir, "session-readers.lock", 0o600); fs.flockOwnedLock(lock, "exclusive", "nonblocking"); borrowed = BorrowedWorkspaceSessionReadersExclusiveLockLease.make(this.workspaceId, this.rootIdentity); return action(borrowed); }); return result; } - finally { borrowed?.invalidate(); let failed = false; try { lock?.close(); } catch { failed = true; } if (failed) { this.live = false; } this.readerExclusive = false; if (failed) throw fail(); } + let lock: import("./workspace-lock-root-lease.js").WorkspaceRootLock | undefined; let borrowed: BorrowedWorkspaceSessionReadersExclusiveLockLease | undefined; + try { lock = await this.root.acquireSessionReadersExclusive(); borrowed = BorrowedWorkspaceSessionReadersExclusiveLockLease.make(this.workspaceId, this.rootIdentity); return await action(borrowed); } + catch { throw fail(); } + finally { borrowed?.invalidate(); try { lock?.close(); } catch { this.live = false; } this.readerExclusive = false; } } async spawnChild(_request: WorkspaceLockedChildRequest): Promise { this.check(); throw fail("child runner is not configured"); } async close(): Promise { if (!this.live) return; if (this.readerExclusive) throw fail(); this.live = false; let error: unknown; try { this.writer.close(); } catch (e) { error = e; } try { await this.root.close(); } catch (e) { error ??= e; } if (error) throw fail(); } } +function makeWriterCapability(id: CanonicalWorkspaceId, identity: WorkspaceLockRootIdentityV1, root: VerifiedWorkspaceLockRootLease, writer: import("./workspace-lock-root-lease.js").WorkspaceRootLock): WorkspaceWriterLockCapability { return WorkspaceWriterLockCapability.create(id, identity, root, writer); } export interface OrderedWorkspaceCapability { readonly workspaceId: CanonicalWorkspaceId; readonly rootLease: BorrowedVerifiedWorkspaceLockRootLease; readonly writerCapability: WorkspaceWriterLockCapability; } export class OrderedWorkspaceWriterCapabilitySet { private live = true; private constructor(private readonly caps: Map) {} @@ -132,9 +134,10 @@ export class OrderedWorkspaceWriterCapabilitySet { export async function runUnderOrderedWorkspaceWriterLocks(rootLeases: readonly VerifiedWorkspaceLockRootLease[], action: (capabilities: OrderedWorkspaceWriterCapabilitySet) => Promise): Promise { const sorted = [...rootLeases].sort((a, b) => a.identity.workspaceId.localeCompare(b.identity.workspaceId)); if (new Set(sorted.map(x => x.identity.workspaceId)).size !== sorted.length) throw fail(); const caps: WorkspaceWriterLockCapability[] = []; - try { for (const source of sorted) { const root = source.transfer(); const fs = new WorkspaceFsAtV1(); let cap: WorkspaceWriterLockCapability | undefined; await root._withRoot(async (dir) => { const writer = fs.openOrCreateLockAt(dir, "writer.lock", 0o600); try { fs.flockOwnedLock(writer, "exclusive", "nonblocking"); cap = WorkspaceWriterLockCapability.make(root.identity.workspaceId, root.identity, root, fs, writer); } catch (e) { writer.close(); throw e; } }); if (!cap) throw fail(); caps.push(cap); } + const transferred: VerifiedWorkspaceLockRootLease[] = []; + try { for (const source of sorted) { const root = source.transfer(); transferred.push(root); let writer: import("./workspace-lock-root-lease.js").WorkspaceRootLock | undefined; try { writer = await root.acquireWriterLock(); caps.push(makeWriterCapability(root.identity.workspaceId, root.identity, root, writer)); } catch (error) { try { writer?.close(); } catch {} try { await root.close(); } catch {} throw error; } } const set = OrderedWorkspaceWriterCapabilitySet.make(new Map(caps.map(c => [c.workspaceId, c]))); try { return await action(set); } finally { set.invalidate(); for (const cap of [...caps].reverse()) await cap.close(); } - } catch (error) { for (const cap of [...caps].reverse()) await cap.close().catch(() => undefined); throw error; } + } catch (error) { for (const cap of [...caps].reverse()) await cap.close().catch(() => undefined); for (const root of transferred.slice(caps.length).reverse()) await root.close().catch(() => undefined); throw error; } } export function runUnderWorkspaceWriterLock(rootLease: VerifiedWorkspaceLockRootLease, action: (capability: WorkspaceWriterLockCapability) => Promise) { return runUnderOrderedWorkspaceWriterLocks([rootLease], set => set.forWorkspace(rootLease.identity.workspaceId, x => action(x.writerCapability))); } export async function probeWorkspaceWriterLock(rootLease: VerifiedWorkspaceLockRootLease): Promise<"available" | "held"> { try { await runUnderWorkspaceWriterLock(rootLease, async () => undefined); return "available"; } catch { return "held"; } } diff --git a/backend/src/workspaces/workspace-fs-at.ts b/backend/src/workspaces/workspace-fs-at.ts index 2e0dcc05..9719ecb5 100644 --- a/backend/src/workspaces/workspace-fs-at.ts +++ b/backend/src/workspaces/workspace-fs-at.ts @@ -1,30 +1,72 @@ import { createRequire } from "node:module"; import type { WorkspaceFsAtBindingV1, NativeWorkspaceFsAtHandleV1, NativeWorkspaceFsAtStatV1, NativeWorkspaceFsAtComponentV1 } from "../native/workspace-fs-at-binding.js"; const require = createRequire(import.meta.url); -const binding: WorkspaceFsAtBindingV1 = require("../../native/workspace-fs-at/build/Release/workspace_fs_at.node"); +const binding = require("../../native/workspace-fs-at/build/Release/workspace_fs_at.node") as WorkspaceFsAtBindingV1 & { + withFd(handle: NativeWorkspaceFsAtHandleV1, action: (fd: number) => void): void; + duplicateForChildStdio(writer: NativeWorkspaceFsAtHandleV1, root: NativeWorkspaceFsAtHandleV1, writerFd: number, rootFd: number): void; +}; export interface WorkspaceFsAtStatV1 extends NativeWorkspaceFsAtStatV1 {} export type LockFileName = "writer.lock" | "session-readers.lock"; export type WorkspaceFlockKindV1 = "shared" | "exclusive"; export type WorkspaceFlockWaitV1 = "blocking" | "nonblocking"; -function component(value:string): NativeWorkspaceFsAtComponentV1 { if (typeof value!=="string" || value.length===0 || value.length>255 || value!==value.trim() || value==='.' || value==='..' || value.includes('/') || value.includes('\0')) throw new Error("invalid path component"); if (!isComponent(value)) throw new Error("invalid path component"); return value; } -function isComponent(value:string): value is NativeWorkspaceFsAtComponentV1 { return true; } -function normalizeError(error: unknown): Error { if (error instanceof Error) return error; return new Error(String(error)); } -class Owned { - protected live=true; - constructor(protected readonly raw: NativeWorkspaceFsAtHandleV1, readonly opened: WorkspaceFsAtStatV1) {} - stat(): WorkspaceFsAtStatV1 { if(!this.live) throw new Error("workspace descriptor is closed"); return this.opened; } - close(): void { if(!this.live)return; this.live=false; try { binding.close(this.raw); } catch(e){ throw normalizeError(e); } } - _raw(): NativeWorkspaceFsAtHandleV1 { if(!this.live) throw new Error("workspace descriptor is closed"); return this.raw; } + +function component(value: string): NativeWorkspaceFsAtComponentV1 { + if (typeof value !== "string" || value.length === 0 || Buffer.byteLength(value, "utf8") > 255 || value !== value.trim() || value === "." || value === ".." || value.includes("/") || value.includes("\0")) throw new Error("invalid path component"); + return value as NativeWorkspaceFsAtComponentV1; +} +function normalizeError(error: unknown): Error { + if (error instanceof Error) return error; + return new Error(String(error)); +} +const INTERNAL = Symbol("workspace-fs-at-owned"); +const rawHandles = new WeakMap(); +const borrowing = new WeakMap(); +abstract class Owned { + private live = true; + private borrowing = 0; + protected constructor(raw: NativeWorkspaceFsAtHandleV1, readonly opened: WorkspaceFsAtStatV1, token: symbol) { if (token !== INTERNAL) throw new TypeError("private workspace descriptor"); rawHandles.set(this, raw); borrowing.set(this, 0); } + stat(): WorkspaceFsAtStatV1 { if (!this.live) throw new Error("workspace descriptor is closed"); return this.opened; } + close(): void { + if (!this.live) return; + if ((borrowing.get(this) ?? 0) !== 0) throw Object.assign(new Error("workspace descriptor is borrowed"), { code: "ERR_WORKSPACE_FS_AT_BORROWED" }); + this.live = false; + try { binding.close(rawHandles.get(this)!); } catch (error) { throw normalizeError(error); } + } +} +export class OwnedWorkspaceFsAtDirectory extends Owned { + constructor(raw: NativeWorkspaceFsAtHandleV1, stat: WorkspaceFsAtStatV1, token: symbol) { super(raw, stat, token); } +} +export class OwnedWorkspaceFsAtRegularFile extends Owned { + constructor(raw: NativeWorkspaceFsAtHandleV1, stat: WorkspaceFsAtStatV1, token: symbol) { super(raw, stat, token); } + +} +function wrapDirectory(result: {handle: NativeWorkspaceFsAtHandleV1; openedStat: WorkspaceFsAtStatV1}): OwnedWorkspaceFsAtDirectory { + try { if ((result.openedStat.mode & 0o170000) !== 0o040000) throw new Error("not a directory"); return new OwnedWorkspaceFsAtDirectory(result.handle, result.openedStat, INTERNAL); } + catch (error) { try { binding.close(result.handle); } catch { /* preserve conversion error */ } throw error; } +} +function wrapLock(result: {handle: NativeWorkspaceFsAtHandleV1; openedStat: WorkspaceFsAtStatV1}): OwnedWorkspaceFsAtRegularFile { + try { + const st = result.openedStat; + if ((st.mode & 0o170000) !== 0o100000 || (st.mode & 0o777) !== 0o600 || st.uid !== (process.getuid?.() ?? st.uid) || st.nlink !== 1n) throw new Error("invalid lock identity"); + return new OwnedWorkspaceFsAtRegularFile(result.handle, st, INTERNAL); + } catch (error) { try { binding.close(result.handle); } catch { /* preserve conversion error */ } throw error; } +} +function rawDirectory(value: OwnedWorkspaceFsAtDirectory): NativeWorkspaceFsAtHandleV1 { if (!rawHandles.has(value)) throw new Error("workspace descriptor is closed"); return rawHandles.get(value)!; } +function withLockFd(value: OwnedWorkspaceFsAtRegularFile, action: (fd: number) => T): T { + if (!rawHandles.has(value)) throw new Error("workspace descriptor is closed"); + const count = borrowing.get(value) ?? 0; borrowing.set(value, count + 1); + try { return binding.withFd(rawHandles.get(value)!, action as (fd: number) => void) as T; } finally { borrowing.set(value, count); } } -export class OwnedWorkspaceFsAtDirectory extends Owned { private constructor(raw:NativeWorkspaceFsAtHandleV1, stat:WorkspaceFsAtStatV1){super(raw,stat);} static from(raw:NativeWorkspaceFsAtHandleV1,stat:WorkspaceFsAtStatV1){return new OwnedWorkspaceFsAtDirectory(raw,stat);} } -export class OwnedWorkspaceFsAtRegularFile extends Owned { private constructor(raw:NativeWorkspaceFsAtHandleV1, stat:WorkspaceFsAtStatV1){super(raw,stat);} static from(raw:NativeWorkspaceFsAtHandleV1,stat:WorkspaceFsAtStatV1){return new OwnedWorkspaceFsAtRegularFile(raw,stat);} } -function rawDirectory(value:OwnedWorkspaceFsAtDirectory){ return value._raw(); } export class WorkspaceFsAtV1 { - openRoot(): OwnedWorkspaceFsAtDirectory { const r=binding.openat({parent:null,name:"/",kind:"directory",createMode:0}); return OwnedWorkspaceFsAtDirectory.from(r.handle,r.openedStat); } - openDirectoryAt(parent:OwnedWorkspaceFsAtDirectory, name:string):OwnedWorkspaceFsAtDirectory { const r=binding.openat({parent:rawDirectory(parent),name:component(name),kind:"directory",createMode:0}); return OwnedWorkspaceFsAtDirectory.from(r.handle,r.openedStat); } - openOrCreateLockAt(parent:OwnedWorkspaceFsAtDirectory,name:LockFileName,mode:0o600):OwnedWorkspaceFsAtRegularFile { if(name!=="writer.lock"&&name!=="session-readers.lock"||mode!==0o600)throw new Error("invalid lock"); const r=binding.openat({parent:rawDirectory(parent),name:component(name),kind:"regular_lock",createMode:0o600}); return OwnedWorkspaceFsAtRegularFile.from(r.handle,r.openedStat); } - mkdirAt(parent:OwnedWorkspaceFsAtDirectory,name:string,mode:0o700):void { binding.mkdirat(rawDirectory(parent),component(name),mode); } - statAtNoFollow(parent:OwnedWorkspaceFsAtDirectory,name:string):WorkspaceFsAtStatV1 { return binding.fstatat(rawDirectory(parent),component(name)); } - fsyncDirectory(directory:OwnedWorkspaceFsAtDirectory):void { binding.fsyncDirectory(rawDirectory(directory)); } - flockOwnedLock(owned:OwnedWorkspaceFsAtRegularFile,kind:WorkspaceFlockKindV1,wait:WorkspaceFlockWaitV1):void { const fd=binding.fdNumberForSynchronousBorrow(owned._raw()); const fsExt: {flockSync(fd:number,operation:string):void}=require("fs-ext"); const operation=kind==="shared"?(wait==="blocking"?"sh":"shnb"):(wait==="blocking"?"ex":"exnb"); fsExt.flockSync(fd,operation); } + openRoot(): OwnedWorkspaceFsAtDirectory { return wrapDirectory(binding.openat({ parent: null, name: "/", kind: "directory", createMode: 0 })); } + openDirectoryAt(parent: OwnedWorkspaceFsAtDirectory, name: string): OwnedWorkspaceFsAtDirectory { return wrapDirectory(binding.openat({ parent: rawDirectory(parent), name: component(name), kind: "directory", createMode: 0 })); } + openOrCreateLockAt(parent: OwnedWorkspaceFsAtDirectory, name: LockFileName, mode: 0o600): OwnedWorkspaceFsAtRegularFile { if ((name !== "writer.lock" && name !== "session-readers.lock") || mode !== 0o600) throw new Error("invalid lock"); return wrapLock(binding.openat({ parent: rawDirectory(parent), name: component(name), kind: "regular_lock", createMode: 0o600 })); } + mkdirAt(parent: OwnedWorkspaceFsAtDirectory, name: string, mode: 0o700): void { binding.mkdirat(rawDirectory(parent), component(name), mode); } + statAtNoFollow(parent: OwnedWorkspaceFsAtDirectory, name: string): WorkspaceFsAtStatV1 { return binding.fstatat(rawDirectory(parent), component(name)); } + fsyncDirectory(directory: OwnedWorkspaceFsAtDirectory): void { binding.fsyncDirectory(rawDirectory(directory)); } + flockOwnedLock(owned: OwnedWorkspaceFsAtRegularFile, kind: WorkspaceFlockKindV1, wait: WorkspaceFlockWaitV1): void { + const fsExt: { flockSync(fd: number, operation: string): void } = require("fs-ext"); + const operation = kind === "shared" ? (wait === "blocking" ? "sh" : "shnb") : (wait === "blocking" ? "ex" : "exnb"); + withLockFd(owned, fd => fsExt.flockSync(fd, operation)); + } } diff --git a/backend/src/workspaces/workspace-lock-root-lease.ts b/backend/src/workspaces/workspace-lock-root-lease.ts index 4920f4e9..18c9d23c 100644 --- a/backend/src/workspaces/workspace-lock-root-lease.ts +++ b/backend/src/workspaces/workspace-lock-root-lease.ts @@ -1,41 +1,79 @@ -import { WorkspaceFsAtV1, OwnedWorkspaceFsAtDirectory, type OwnedWorkspaceFsAtRegularFile, type WorkspaceFsAtStatV1 } from "./workspace-fs-at.js"; +import { lstatSync, realpathSync } from "node:fs"; +import { resolve } from "node:path"; +import { WorkspaceFsAtV1, OwnedWorkspaceFsAtDirectory, type OwnedWorkspaceFsAtRegularFile, type WorkspaceFsAtStatV1, type WorkspaceFlockKindV1, type WorkspaceFlockWaitV1 } from "./workspace-fs-at.js"; export type CanonicalWorkspaceId = string & { readonly __workspaceId: unique symbol }; -export interface WorkspaceLockRootIdentityV1 { readonly schemaVersion:1; readonly workspaceId:CanonicalWorkspaceId; readonly device:bigint; readonly inode:bigint; } -export class CanonicalWorkspaceLockRootInput { private constructor(readonly workspaceId:CanonicalWorkspaceId, readonly owner:symbol){} static make(id:CanonicalWorkspaceId,owner:symbol){return new CanonicalWorkspaceLockRootInput(id,owner);} } -export class BorrowedVerifiedWorkspaceLockRootLease { private constructor(readonly identity:WorkspaceLockRootIdentityV1, private readonly check:()=>void){} static make(i:WorkspaceLockRootIdentityV1,c:()=>void){return new BorrowedVerifiedWorkspaceLockRootLease(i,c);} assertLive(){this.check();} } -function conflict(message="preprocessing conflict"):Error { const e=new Error(message); e.name="PreprocessingConflictError"; return e; } -function exactStat(stat:WorkspaceFsAtStatV1,uid:number):boolean { return (stat.mode&0o170000)===0o040000 && (stat.mode&0o777)===0o700 && stat.uid===uid && stat.nlink>=2n; } +export interface WorkspaceLockRootIdentityV1 { readonly schemaVersion: 1; readonly workspaceId: CanonicalWorkspaceId; readonly device: bigint; readonly inode: bigint; } +export class CanonicalWorkspaceLockRootInput { private constructor(readonly workspaceId: CanonicalWorkspaceId, readonly owner: symbol) {} } +function conflict(message = "preprocessing_conflict"): Error { const e = new Error(message); e.name = "PreprocessingConflictError"; return e; } +function exactRoot(stat: WorkspaceFsAtStatV1, uid: number): boolean { return (stat.mode & 0o170000) === 0o040000 && (stat.mode & 0o777) === 0o700 && stat.uid === uid && stat.nlink >= 2n; } +function sameIdentity(a: {device: bigint|number; inode: bigint|number} | {dev: bigint|number; ino: bigint|number}, b: {device: bigint; inode: bigint}): boolean { const device = BigInt("device" in a ? a.device : a.dev); const inode = BigInt("inode" in a ? a.inode : a.ino); return device === b.device && inode === b.inode; } + +/** Internal opaque lock returned only after the root has been checked. */ +export class WorkspaceRootLock { + private live = true; + constructor(private readonly fs: WorkspaceFsAtV1, private readonly lock: OwnedWorkspaceFsAtRegularFile) {} + flock(kind: WorkspaceFlockKindV1, wait: WorkspaceFlockWaitV1): void { if (!this.live) throw conflict(); this.fs.flockOwnedLock(this.lock, kind, wait); } + close(): void { if (!this.live) return; this.live = false; this.lock.close(); } +} +export class BorrowedVerifiedWorkspaceLockRootLease { + private constructor(readonly identity: WorkspaceLockRootIdentityV1, private readonly check: () => void) {} + static make(identity: WorkspaceLockRootIdentityV1, check: () => void): BorrowedVerifiedWorkspaceLockRootLease { return new BorrowedVerifiedWorkspaceLockRootLease(identity, check); } + assertLive(): void { this.check(); } +} export class WorkspaceSessionReadersLockLease { - private live=true; private constructor(private readonly lock:OwnedWorkspaceFsAtRegularFile, private readonly root:OwnedWorkspaceFsAtDirectory, readonly rootIdentity:WorkspaceLockRootIdentityV1){} - static make(lock:OwnedWorkspaceFsAtRegularFile,root:OwnedWorkspaceFsAtDirectory,id:WorkspaceLockRootIdentityV1){return new WorkspaceSessionReadersLockLease(lock,root,id);} - transfer():WorkspaceSessionReadersLockLease { if(!this.live)throw conflict(); this.live=false; return WorkspaceSessionReadersLockLease.make(this.lock,this.root,this.rootIdentity); } - async close():Promise{if(!this.live)return;this.live=false;let failure:unknown;try{this.lock.close();}catch(e){failure=e;}try{this.root.close();}catch(e){failure??=e;}if(failure)throw conflict();} + private live = true; + private constructor(private readonly lock: WorkspaceRootLock, private readonly root: OwnedWorkspaceFsAtDirectory, readonly rootIdentity: WorkspaceLockRootIdentityV1) {} + transfer(): WorkspaceSessionReadersLockLease { if (!this.live) throw conflict(); this.live = false; return new WorkspaceSessionReadersLockLease(this.lock, this.root, this.rootIdentity); } + async close(): Promise { if (!this.live) return; this.live = false; let failure: unknown; try { this.lock.close(); } catch (e) { failure = e; } try { this.root.close(); } catch (e) { failure ??= e; } if (failure) throw conflict(); } + static from(lock: WorkspaceRootLock, root: OwnedWorkspaceFsAtDirectory, identity: WorkspaceLockRootIdentityV1): WorkspaceSessionReadersLockLease { return new WorkspaceSessionReadersLockLease(lock, root, identity); } } export class VerifiedWorkspaceLockRootLease { - private live=true; private borrowed=0; private constructor(private readonly owner:symbol,private readonly fs:WorkspaceFsAtV1,private readonly root:OwnedWorkspaceFsAtDirectory,readonly identity:WorkspaceLockRootIdentityV1){} - static make(owner:symbol,fs:WorkspaceFsAtV1,root:OwnedWorkspaceFsAtDirectory,id:WorkspaceLockRootIdentityV1){return new VerifiedWorkspaceLockRootLease(owner,fs,root,id);} - private check(){if(!this.live)throw conflict(); const s=this.root.stat();if(s.device!==this.identity.device||s.inode!==this.identity.inode)throw conflict("workspace root identity changed");} - async borrow(action:(b:BorrowedVerifiedWorkspaceLockRootLease)=>Promise):Promise{this.check();this.borrowed++;try{return await action(BorrowedVerifiedWorkspaceLockRootLease.make(this.identity,()=>this.check()));}finally{this.borrowed--;}} - transfer():VerifiedWorkspaceLockRootLease{this.check();if(this.borrowed)throw conflict("workspace root is borrowed");this.live=false;return VerifiedWorkspaceLockRootLease.make(this.owner,this.fs,this.root,this.identity);} - async acquireSessionReadersShared():Promise{this.check();let lock:OwnedWorkspaceFsAtRegularFile|undefined;try{lock=this.fs.openOrCreateLockAt(this.root,"session-readers.lock",0o600);this.fs.flockOwnedLock(lock,"shared","nonblocking");this.check();this.live=false;return WorkspaceSessionReadersLockLease.make(lock,this.root,this.identity);}catch(e){try{lock?.close();}catch{this.live=false;try{this.root.close();}catch{}}throw conflict();}} - async close():Promise{if(!this.live)return;if(this.borrowed)await new Promise(resolve=>{const tick=()=>this.borrowed?setTimeout(tick,1):resolve();tick();});this.live=false;try{this.root.close();}catch{throw conflict();}} - // package-private operation used by the writer capability; never returns the underlying handle. - async _withRoot(action:(root:OwnedWorkspaceFsAtDirectory,fs:WorkspaceFsAtV1)=>Promise):Promise{this.check();return action(this.root,this.fs);} + private live = true; + private borrowed = 0; + private constructor(private readonly owner: symbol, private readonly fs: WorkspaceFsAtV1, private readonly root: OwnedWorkspaceFsAtDirectory, readonly identity: WorkspaceLockRootIdentityV1, private readonly rootPath: string, private readonly serviceUid: number) {} + private assertPath(): void { + if (!this.live) throw conflict(); + try { const st = lstatSync(this.rootPath); if (!st.isDirectory() || st.uid !== this.serviceUid || (st.mode & 0o777) !== 0o700 || !sameIdentity(st, this.identity)) throw conflict("preprocessing_conflict: workspace root identity changed"); } + catch (error) { if ((error as Error).name === "PreprocessingConflictError") throw error; throw conflict("preprocessing_conflict: workspace root identity changed"); } + const retained = this.root.stat(); if (!sameIdentity(retained, this.identity) || !exactRoot(retained, this.serviceUid)) throw conflict("preprocessing_conflict: workspace root identity changed"); + } + assertLive(): void { this.assertPath(); } + async borrow(action: (lease: { readonly identity: WorkspaceLockRootIdentityV1; assertLive(): void }) => Promise): Promise { this.assertPath(); this.borrowed++; try { return await action({ identity: this.identity, assertLive: () => this.assertPath() }); } finally { this.borrowed--; } } + transfer(): VerifiedWorkspaceLockRootLease { this.assertPath(); if (this.borrowed) throw conflict("workspace root is borrowed"); this.live = false; return new VerifiedWorkspaceLockRootLease(this.owner, this.fs, this.root, this.identity, this.rootPath, this.serviceUid); } + async acquireSessionReadersShared(): Promise { + this.assertPath(); let lock: WorkspaceRootLock | undefined; + try { const owned = this.fs.openOrCreateLockAt(this.root, "session-readers.lock", 0o600); lock = new WorkspaceRootLock(this.fs, owned); lock.flock("shared", "nonblocking"); this.assertPath(); this.live = false; return WorkspaceSessionReadersLockLease.from(lock, this.root, this.identity); } + catch (error) { try { lock?.close(); } catch { this.live = false; try { this.root.close(); } catch {} } throw conflict(); } + } + async acquireWriterLock(): Promise { this.assertPath(); const owned = this.fs.openOrCreateLockAt(this.root, "writer.lock", 0o600); const lock = new WorkspaceRootLock(this.fs, owned); try { lock.flock("exclusive", "nonblocking"); this.assertPath(); return lock; } catch (error) { try { lock.close(); } catch {} throw conflict(); } } + async acquireSessionReadersExclusive(): Promise { this.assertPath(); const owned = this.fs.openOrCreateLockAt(this.root, "session-readers.lock", 0o600); const lock = new WorkspaceRootLock(this.fs, owned); try { lock.flock("exclusive", "nonblocking"); this.assertPath(); return lock; } catch { try { lock.close(); } catch {} throw conflict(); } } + fsync(): void { this.assertPath(); this.fs.fsyncDirectory(this.root); this.assertPath(); } + async close(): Promise { if (!this.live) return; while (this.borrowed) await new Promise(resolve => setTimeout(resolve, 1)); this.live = false; try { this.root.close(); } catch { throw conflict(); } } + static from(owner: symbol, fs: WorkspaceFsAtV1, root: OwnedWorkspaceFsAtDirectory, identity: WorkspaceLockRootIdentityV1, rootPath: string, uid: number): VerifiedWorkspaceLockRootLease { return new VerifiedWorkspaceLockRootLease(owner, fs, root, identity, rootPath, uid); } } export class VerifiedWorkspaceLockRootLeaseFactory { - private readonly owner=Symbol(); private readonly parent:OwnedWorkspaceFsAtDirectory; - constructor(private readonly input:{readonly workspaceFsAt:WorkspaceFsAtV1;readonly installationId:string;readonly sessionsRootFromValidatedInstallationConfig:string;readonly serviceUid:number;readonly provisionedWorkspaceMode:0o700}) { if(!Number.isInteger(input.serviceUid)||input.serviceUid<0)throw new Error("invalid service uid");if(input.provisionedWorkspaceMode!==0o700)throw new Error("invalid workspace mode");if(!input.sessionsRootFromValidatedInstallationConfig.startsWith("/"))throw new Error("sessions root must be absolute");let d=input.workspaceFsAt.openRoot();try{for(const c of input.sessionsRootFromValidatedInstallationConfig.split("/").filter(Boolean)) {const n=input.workspaceFsAt.openDirectoryAt(d,c);d.close();d=n;}this.parent=d;}catch(e){try{d.close();}catch{}throw e;}} - canonicalInput(workspaceId:string):CanonicalWorkspaceLockRootInput{if(!/^[a-z][a-z0-9-]{2,62}$/.test(workspaceId))throw conflict();return CanonicalWorkspaceLockRootInput.make(workspaceId as CanonicalWorkspaceId,this.owner);} - private async open(input:CanonicalWorkspaceLockRootInput):Promise{if(input.owner!==this.owner)throw conflict();const root=this.input.workspaceFsAt.openDirectoryAt(this.parent,input.workspaceId);if(!exactStat(root.stat(),this.input.serviceUid)){root.close();throw conflict();}return VerifiedWorkspaceLockRootLease.make(this.owner,this.input.workspaceFsAt,root,{schemaVersion:1,workspaceId:input.workspaceId,device:root.stat().device,inode:root.stat().inode});} - acquire(input:CanonicalWorkspaceLockRootInput){return this.open(input);} - async acquireOrProvision(input:CanonicalWorkspaceLockRootInput){ - if(input.owner!==this.owner)throw conflict(); - try{return await this.open(input);}catch(error){if((error as {code?:unknown})?.code!=="ENOENT")throw conflict();} - try{this.input.workspaceFsAt.mkdirAt(this.parent,input.workspaceId,0o700);}catch(error){if((error as {code?:unknown})?.code!=="EEXIST")throw conflict();} - try{this.input.workspaceFsAt.fsyncDirectory(this.parent);}catch{throw conflict();} - const lease=await this.open(input); - try{await lease._withRoot(async(dir,fs)=>fs.fsyncDirectory(dir));} - catch{await lease.close().catch(()=>undefined);throw conflict();} - return lease; + private readonly owner = Symbol("workspace-root-factory"); + private readonly parent: OwnedWorkspaceFsAtDirectory; + private readonly parentPath: string; + constructor(private readonly input: { readonly workspaceFsAt: WorkspaceFsAtV1; readonly installationId: string; readonly sessionsRootFromValidatedInstallationConfig: string; readonly serviceUid: number; readonly provisionedWorkspaceMode: 0o700 }) { + if (!Number.isInteger(input.serviceUid) || input.serviceUid < 0 || input.provisionedWorkspaceMode !== 0o700) throw new Error("invalid workspace root policy"); + try { const configured = resolve(input.sessionsRootFromValidatedInstallationConfig); this.parentPath = realpathSync(configured); if (this.parentPath !== configured) throw conflict("sessions root must be canonical"); } catch (error) { if ((error as Error).name === "PreprocessingConflictError") throw error; throw conflict("invalid sessions root"); } + if (!this.parentPath.startsWith("/") || this.parentPath.split("/").includes("..")) throw conflict("invalid sessions root"); + let d = input.workspaceFsAt.openRoot(); + try { for (const c of this.parentPath.split("/").filter(Boolean)) { const n = input.workspaceFsAt.openDirectoryAt(d, c); d.close(); d = n; } this.parent = d; this.checkParent(); } + catch (error) { try { d.close(); } catch {} throw error; } + } + private checkParent(): void { try { const s = lstatSync(this.parentPath); const p = this.parent.stat(); if (!s.isDirectory() || !sameIdentity(s, p) || s.uid !== this.input.serviceUid || (s.mode & 0o777) !== 0o700) throw conflict("installation root identity changed"); } catch (error) { if ((error as Error).name === "PreprocessingConflictError") throw error; throw conflict("installation root identity changed"); } } + canonicalInput(workspaceId: string): CanonicalWorkspaceLockRootInput { if (!/^[a-z][a-z0-9-]{2,62}$/.test(workspaceId)) throw conflict(); return Object.assign(Object.create(CanonicalWorkspaceLockRootInput.prototype), { workspaceId: workspaceId as CanonicalWorkspaceId, owner: this.owner }) as CanonicalWorkspaceLockRootInput; } + private async open(input: CanonicalWorkspaceLockRootInput): Promise { if (input.owner !== this.owner) throw conflict(); this.checkParent(); let root: OwnedWorkspaceFsAtDirectory; try { root = this.input.workspaceFsAt.openDirectoryAt(this.parent, input.workspaceId); this.checkParent(); } catch { throw conflict(); } if (!exactRoot(root.stat(), this.input.serviceUid)) { root.close(); throw conflict(); } const identity = { schemaVersion: 1 as const, workspaceId: input.workspaceId, device: root.stat().device, inode: root.stat().inode }; const lease = VerifiedWorkspaceLockRootLease.from(this.owner, this.input.workspaceFsAt, root, identity, `${this.parentPath}/${input.workspaceId}`, this.input.serviceUid); try { lease.assertLive(); } catch { await lease.close().catch(() => undefined); throw conflict(); } return lease; } + acquire(input: CanonicalWorkspaceLockRootInput): Promise { return this.open(input); } + async acquireOrProvision(input: CanonicalWorkspaceLockRootInput): Promise { + if (input.owner !== this.owner) throw conflict(); + try { return await this.open(input); } catch (error) { if (!String((error as Error).message).includes("identity") && (error as {code?: string}).code !== "ENOENT") { /* open errors are normalized; probe the anchored parent below */ } } + this.checkParent(); + try { this.input.workspaceFsAt.mkdirAt(this.parent, input.workspaceId, 0o700); } catch (error) { if ((error as {code?: string}).code !== "EEXIST") throw conflict(); } + this.checkParent(); + try { this.input.workspaceFsAt.fsyncDirectory(this.parent); } catch { throw conflict(); } + return this.open(input); } } diff --git a/backend/test/fixtures/workspace-fs-at-race-worker.mjs b/backend/test/fixtures/workspace-fs-at-race-worker.mjs index 3996db0d..d0684fe0 100644 --- a/backend/test/fixtures/workspace-fs-at-race-worker.mjs +++ b/backend/test/fixtures/workspace-fs-at-race-worker.mjs @@ -1 +1,5 @@ -process.stdout.write(JSON.stringify({ok:true})); +import { createRequire } from "node:module"; import { resolve } from "node:path"; +const require = createRequire(import.meta.url); const a = require(resolve(process.cwd(), "native/workspace-fs-at/build/Release/workspace_fs_at.node")); +let root = a.openat({parent:null,name:"/",kind:"directory",createMode:0}).handle; let successes=0, failures=0; +for (let i=0;i fsExt.flockSync(fd, process.env.LOCK_MODE ?? "exnb")); +process.stdout.write(JSON.stringify({ ready: true })); +await sleep(Number(process.env.HOLD_MS ?? 100)); +addon.close(lock); addon.close(root); diff --git a/backend/test/fixtures/workspace-registry-addressed-worker.mjs b/backend/test/fixtures/workspace-registry-addressed-worker.mjs index 3996db0d..cc7d872c 100644 --- a/backend/test/fixtures/workspace-registry-addressed-worker.mjs +++ b/backend/test/fixtures/workspace-registry-addressed-worker.mjs @@ -1 +1 @@ -process.stdout.write(JSON.stringify({ok:true})); +import { mkdir, writeFile } from "node:fs/promises"; const root=process.env.JOB_ROOT; if (!root) throw new Error("JOB_ROOT required"); await mkdir(root,{recursive:true,mode:0o700}); const id=process.env.RUN_ID??"a".repeat(32); await writeFile(`${root}/${id}.json`,JSON.stringify({runId:id,phase:"request_claimed"})+"\n",{flag:"wx",mode:0o600}); process.stdout.write(JSON.stringify({ok:true,runId:id})); diff --git a/backend/test/fixtures/workspace-session-readers-worker.mjs b/backend/test/fixtures/workspace-session-readers-worker.mjs index 3996db0d..2de8dbb0 100644 --- a/backend/test/fixtures/workspace-session-readers-worker.mjs +++ b/backend/test/fixtures/workspace-session-readers-worker.mjs @@ -1 +1,14 @@ -process.stdout.write(JSON.stringify({ok:true})); +import { createRequire } from "node:module"; +import { setTimeout as sleep } from "node:timers/promises"; +import { resolve } from "node:path"; +const require = createRequire(import.meta.url); +const addon = require(resolve(process.cwd(), "native/workspace-fs-at/build/Release/workspace_fs_at.node")); +const fsExt = require("fs-ext"); +let root = addon.openat({ parent: null, name: "/", kind: "directory", createMode: 0 }).handle; +for (const p of (process.env.WORKSPACE_ROOT ?? "").split("/").filter(Boolean)) root = addon.openat({ parent: root, name: p, kind: "directory", createMode: 0 }).handle; +const lockName = process.env.LOCK_NAME ?? "session-readers.lock"; +const lock = addon.openat({ parent: root, name: lockName, kind: "regular_lock", createMode: 0o600 }).handle; +addon.withFd(lock, fd => fsExt.flockSync(fd, process.env.LOCK_MODE ?? "exnb")); +process.stdout.write(JSON.stringify({ ready: true })); +await sleep(Number(process.env.HOLD_MS ?? 100)); +addon.close(lock); addon.close(root); diff --git a/backend/test/workspace-fs-at-native.test.ts b/backend/test/workspace-fs-at-native.test.ts index d4ee4b52..c2ead5d2 100644 --- a/backend/test/workspace-fs-at-native.test.ts +++ b/backend/test/workspace-fs-at-native.test.ts @@ -1,4 +1,39 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, afterEach } from "vitest"; +import { mkdtempSync, mkdirSync, chmodSync, writeFileSync, linkSync, symlinkSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { createRequire } from "node:module"; +const require = createRequire(import.meta.url); import { WorkspaceFsAtV1 } from "../src/workspaces/workspace-fs-at.js"; -import { mkdtempSync, mkdirSync, statSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -describe("workspace fs-at seam",()=>{it("opens anchored directories and literal locks",()=>{const root=mkdtempSync(join(process.cwd(),"thoth-fsat-")); mkdirSync(join(root,"sessions"),{mode:0o700}); const fs=new WorkspaceFsAtV1(); const d=fs.openRoot(); const s=fs.openDirectoryAt(d,"private"); expect(s.stat().mode).toBeTruthy(); s.close(); d.close();});}); +const roots: string[] = []; +function openAbsolute(fs: WorkspaceFsAtV1, path: string) { let d = fs.openRoot(); for (const component of path.split("/").filter(Boolean)) { const next = fs.openDirectoryAt(d, component); d.close(); d = next; } return d; } +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); + +describe("workspace fs-at native seam", () => { + it("exposes only opaque filesystem operations and preserves exact stat identity", () => { + const root = mkdtempSync(join(process.cwd(), "thoth-fsat-")); roots.push(root); mkdirSync(join(root, "private"), { mode: 0o700 }); + const fs = new WorkspaceFsAtV1(); const d = openAbsolute(fs, root); const child = fs.openDirectoryAt(d, "private"); + expect(child.stat().mode & 0o170000).toBe(0o040000); + expect(Object.keys(require("../native/workspace-fs-at/build/Release/workspace_fs_at.node"))).toEqual(["openat", "mkdirat", "fstatat", "fsyncDirectory", "close"]); + expect((child as unknown as Record)._raw).toBeUndefined(); + child.close(); d.close(); + }); + it("rejects invalid and overlong UTF-8 components without following links", () => { + const fs = new WorkspaceFsAtV1(); const root = fs.openRoot(); + for (const name of ["", ".", "..", "a/b", "a\0b", "é".repeat(128)]) expect(() => fs.openDirectoryAt(root, name)).toThrow(); + expect(() => fs.openDirectoryAt(root, "does-not-exist")).toThrow(); root.close(); + }); + it("creates only single-link 0600 lock files and refuses aliases", () => { + const root = mkdtempSync(join(process.cwd(), "thoth-fsat-")); roots.push(root); const fs = new WorkspaceFsAtV1(); const d = openAbsolute(fs, root); const dir = d; + const lock = fs.openOrCreateLockAt(dir, "writer.lock", 0o600); expect(lock.stat().mode & 0o777).toBe(0o600); expect(lock.stat().nlink).toBe(1n); lock.close(); dir.close(); d.close(); + const bad = join(root, "bad"); writeFileSync(bad, "x", { mode: 0o600 }); rmSync(join(root, "writer.lock")); linkSync(bad, join(root, "writer.lock")); + const d2 = openAbsolute(fs, root); expect(() => fs.openOrCreateLockAt(d2, "writer.lock", 0o600)).toThrow(); d2.close(); + }); + it("maps real flock contention to nonblocking failure and shared compatibility", () => { + const root = mkdtempSync(join(process.cwd(), "thoth-fsat-")); roots.push(root); const fs = new WorkspaceFsAtV1(); const d = openAbsolute(fs, root); const dir = d; const a = fs.openOrCreateLockAt(dir, "writer.lock", 0o600); const b = fs.openOrCreateLockAt(dir, "writer.lock", 0o600); + fs.flockOwnedLock(a, "exclusive", "nonblocking"); expect(() => fs.flockOwnedLock(b, "shared", "nonblocking")).toThrow(); a.close(); fs.flockOwnedLock(b, "exclusive", "nonblocking"); b.close(); dir.close(); d.close(); + }); + it("contains close during a synchronous borrow and has a source-level no-retry close contract", async () => { + const source = await import("node:fs/promises").then(() => require("node:fs").readFileSync("native/workspace-fs-at/workspace_fs_at.cc", "utf8")); + expect(source).toContain("ERR_WORKSPACE_FS_AT_CLOSE_UNCERTAIN"); expect(source).not.toMatch(/do\s*\{[^}]*close\([^)]*\)[^}]*\}\s*while[^;]*EINTR/s); + }); +}); diff --git a/backend/test/workspace-lock-root-lease.test.ts b/backend/test/workspace-lock-root-lease.test.ts index 671daa5f..0e0d98fa 100644 --- a/backend/test/workspace-lock-root-lease.test.ts +++ b/backend/test/workspace-lock-root-lease.test.ts @@ -1,2 +1,30 @@ -import { describe, expect, it } from "vitest"; import { mkdtempSync,mkdirSync } from "node:fs"; import {join} from "node:path"; import {tmpdir,getuid} from "node:os"; import {WorkspaceFsAtV1} from "../src/workspaces/workspace-fs-at.js"; import {VerifiedWorkspaceLockRootLeaseFactory} from "../src/workspaces/workspace-lock-root-lease.js"; -describe("retained root lease",()=>{it("provisions a 0700 root and transfers ownership",async()=>{const p=mkdtempSync(join(process.cwd(),"thoth-root-")); const fs=new WorkspaceFsAtV1(); const f=new VerifiedWorkspaceLockRootLeaseFactory({workspaceFsAt:fs,installationId:"i",sessionsRootFromValidatedInstallationConfig:p,serviceUid:process.getuid!(),provisionedWorkspaceMode:0o700}); const l=await f.acquireOrProvision(f.canonicalInput("abc-workspace")); expect(l.identity.workspaceId).toBe("abc-workspace"); const t=l.transfer(); await expect(l.close()).resolves.toBeUndefined(); await t.close();});}); +import { describe, expect, it, afterEach } from "vitest"; +import { mkdtempSync, renameSync, mkdirSync, rmSync, statSync, chmodSync, symlinkSync } from "node:fs"; +import { join } from "node:path"; +import { WorkspaceFsAtV1 } from "../src/workspaces/workspace-fs-at.js"; +import { VerifiedWorkspaceLockRootLeaseFactory } from "../src/workspaces/workspace-lock-root-lease.js"; +const roots: string[] = []; +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); +function factory(sessionsRootFromValidatedInstallationConfig: string) { return new VerifiedWorkspaceLockRootLeaseFactory({ workspaceFsAt: new WorkspaceFsAtV1(), installationId: "i", sessionsRootFromValidatedInstallationConfig, serviceUid: process.getuid!(), provisionedWorkspaceMode: 0o700 }); } + +describe("retained canonical workspace root", () => { + it("provisions exact identity, transfers once, and rejects a second owner", async () => { + const parent = mkdtempSync(join(process.cwd(), "thoth-root-")); roots.push(parent); const f = factory(parent); const input = f.canonicalInput("abc-workspace"); + const lease = await f.acquireOrProvision(input); const st = statSync(join(parent, "abc-workspace")); expect(st.uid).toBe(process.getuid!()); expect(st.mode & 0o777).toBe(0o700); expect(lease.identity.inode).toBe(BigInt(st.ino)); + const transferred = lease.transfer(); expect(() => lease.transfer()).toThrow(); await transferred.close(); + expect(() => f.canonicalInput("../outside")).toThrow(); expect(() => f.canonicalInput("/tmp/x")).toThrow(); + }); + it("fails closed when the canonical pathname is replaced after retention", async () => { + const parent = mkdtempSync(join(process.cwd(), "thoth-root-")); roots.push(parent); const f = factory(parent); const lease = await f.acquireOrProvision(f.canonicalInput("abc-workspace")); + renameSync(join(parent, "abc-workspace"), join(parent, "old")); mkdirSync(join(parent, "abc-workspace"), { mode: 0o700 }); + await expect(lease.acquireWriterLock()).rejects.toThrow(/preprocessing/); await lease.close(); + }); + it("does not accept a symlink or wrong ownership/mode root", async () => { + const parent = mkdtempSync(join(process.cwd(), "thoth-root-")); roots.push(parent); const other = mkdtempSync(join(process.cwd(), "thoth-other-")); roots.push(other); symlinkSync(other, join(parent, "abc-workspace")); + const f = factory(parent); await expect(f.acquireOrProvision(f.canonicalInput("abc-workspace"))).rejects.toThrow(); rmSync(join(parent, "abc-workspace")); mkdirSync(join(parent, "abc-workspace"), { mode: 0o755 }); await expect(f.acquireOrProvision(f.canonicalInput("abc-workspace"))).rejects.toThrow(); + }); + it("closes every transferred root on partial ordered acquisition", async () => { + const parent = mkdtempSync(join(process.cwd(), "thoth-root-")); roots.push(parent); const f = factory(parent); const a = await f.acquireOrProvision(f.canonicalInput("aaa-workspace")); const b = await f.acquireOrProvision(f.canonicalInput("bbb-workspace")); + const { runUnderOrderedWorkspaceWriterLocks } = await import("../src/workspaces/preprocessing-state.js"); await runUnderOrderedWorkspaceWriterLocks([a, b], async set => { expect(set.workspaceIds).toEqual(["aaa-workspace", "bbb-workspace"]); }); await expect(b.close()).resolves.toBeUndefined(); + }); +}); diff --git a/backend/test/workspace-session-readers-lock.test.ts b/backend/test/workspace-session-readers-lock.test.ts index 5da99fe8..05eebe8a 100644 --- a/backend/test/workspace-session-readers-lock.test.ts +++ b/backend/test/workspace-session-readers-lock.test.ts @@ -1,2 +1,17 @@ -import {describe,expect,it} from "vitest"; import {mkdtempSync} from "node:fs"; import {join} from "node:path"; import {tmpdir} from "node:os"; import {WorkspaceFsAtV1} from "../src/workspaces/workspace-fs-at.js"; import {VerifiedWorkspaceLockRootLeaseFactory} from "../src/workspaces/workspace-lock-root-lease.js"; -describe("session reader lease",()=>{it("shares the retained root after acquisition",async()=>{const p=mkdtempSync(join(process.cwd(),"thoth-readers-")); const fs=new WorkspaceFsAtV1(); const f=new VerifiedWorkspaceLockRootLeaseFactory({workspaceFsAt:fs,installationId:"i",sessionsRootFromValidatedInstallationConfig:p,serviceUid:process.getuid!(),provisionedWorkspaceMode:0o700}); const l=await f.acquireOrProvision(f.canonicalInput("abc-workspace")); const r=await l.acquireSessionReadersShared(); expect(r.rootIdentity.workspaceId).toBe("abc-workspace"); await r.close();});}); +import { describe, expect, it, afterEach } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; import { join } from "node:path"; import { spawn } from "node:child_process"; import { once } from "node:events"; +import { WorkspaceFsAtV1 } from "../src/workspaces/workspace-fs-at.js"; import { VerifiedWorkspaceLockRootLeaseFactory } from "../src/workspaces/workspace-lock-root-lease.js"; +const roots: string[] = []; afterEach(() => { for (const r of roots.splice(0)) rmSync(r, { recursive:true, force:true }); }); +function factory(root:string) { return new VerifiedWorkspaceLockRootLeaseFactory({workspaceFsAt:new WorkspaceFsAtV1(),installationId:"i",sessionsRootFromValidatedInstallationConfig:root,serviceUid:process.getuid!(),provisionedWorkspaceMode:0o700}); } + +describe("session reader lease", () => { + it("holds a real shared flock across child lifetime and releases exactly once", async () => { + const parent=mkdtempSync(join(process.cwd(),"thoth-readers-")); roots.push(parent); const f=factory(parent); const lease=await f.acquireOrProvision(f.canonicalInput("abc-workspace")); + const shared=await lease.acquireSessionReadersShared(); const child=spawn(process.execPath,[join(process.cwd(),"test/fixtures/workspace-session-readers-worker.mjs")],{cwd:process.cwd(),env:{...process.env,WORKSPACE_ROOT:join(parent,"abc-workspace"),LOCK_MODE:"exnb",HOLD_MS:"20"},stdio:["ignore","pipe","pipe"]}); + const [code]=await once(child,"close"); expect(code).toBe(1); await shared.close(); + const child2=spawn(process.execPath,[join(process.cwd(),"test/fixtures/workspace-session-readers-worker.mjs")],{cwd:process.cwd(),env:{...process.env,WORKSPACE_ROOT:join(parent,"abc-workspace"),LOCK_MODE:"exnb",HOLD_MS:"5"},stdio:["ignore","pipe","pipe"]}); const [code2]=await once(child2,"close"); expect(code2).toBe(0); + }); + it("permits coexisting production shared acquisitions and invalidates the source after transfer", async () => { + const parent=mkdtempSync(join(process.cwd(),"thoth-readers-")); roots.push(parent); const f=factory(parent); const source=await f.acquireOrProvision(f.canonicalInput("abc-workspace")); const second=await f.acquireOrProvision(f.canonicalInput("abc-workspace")); const a=await source.acquireSessionReadersShared(); const b=await second.acquireSessionReadersShared(); expect(a.rootIdentity.inode).toBe(b.rootIdentity.inode); await expect(source.close()).resolves.toBeUndefined(); await a.close(); await b.close(); + }); +}); diff --git a/docker/core.Dockerfile b/docker/core.Dockerfile index 1516d41e..79661db4 100644 --- a/docker/core.Dockerfile +++ b/docker/core.Dockerfile @@ -19,9 +19,9 @@ RUN npm ci --omit=dev \ FROM node:22-bookworm@sha256:7725a5c2c83eed1d36258c66efae14b1ceccd021db9ed1d9559d3335ed3d68ed AS backend-native-build WORKDIR /src/backend COPY backend/package*.json ./ -RUN npm ci -COPY backend/native ./native COPY backend/scripts ./scripts +COPY backend/native ./native +RUN npm ci RUN npm run build:native # ---- Stage 2: backend TypeScript -> dist ---- FROM node:22-bookworm@sha256:7725a5c2c83eed1d36258c66efae14b1ceccd021db9ed1d9559d3335ed3d68ed AS backend-build @@ -29,10 +29,13 @@ WORKDIR /src/backend COPY backend/package*.json ./ RUN npm ci --ignore-scripts COPY backend/src ./src +COPY backend/scripts ./scripts COPY backend/tsconfig*.json ./ RUN npm run build RUN npm prune --omit=dev COPY --from=backend-native-build /src/backend/native/workspace-fs-at/build/Release/workspace_fs_at.node ./native/workspace-fs-at/build/Release/workspace_fs_at.node +# fs-ext is the pinned runtime flock binding; carry its Node-22 build from the compiler stage. +COPY --from=backend-native-build /src/backend/node_modules/fs-ext/build ./node_modules/fs-ext/build # ---- Stage 2: runtime (Python 3.12 nativo + Node 22 copiato, stesso glibc bookworm) ---- FROM python:3.12-slim-bookworm@sha256:d50fb7611f86d04a3b0471b46d7557818d88983fc3136726336b2a4c657aa30b AS runtime @@ -85,6 +88,7 @@ RUN ln -s /opt/venv /app/harness/.venv # Backend: dist + node_modules (stesso Node major 22 + glibc bookworm → compatibili) COPY --from=backend-build /src/backend/dist /app/backend/dist COPY --from=backend-build /src/backend/node_modules /app/backend/node_modules +COPY --from=backend-build /src/backend/native/workspace-fs-at/build/Release/workspace_fs_at.node /app/backend/native/workspace-fs-at/build/Release/workspace_fs_at.node COPY backend/package*.json /app/backend/ # Runtime Pi is installed only from the committed lockfile. The image exposes its immutable @@ -109,6 +113,9 @@ RUN /usr/local/bin/verify-line-endings /app/docker \ WORKDIR /app/backend USER thoth +# Compiler-free Node 22 runtime smoke: load the repo addon, fsync an anchored directory, and load fs-ext. +RUN node --version | grep -Eq "^v22\." \ + && node -e 'const a=require("/app/backend/native/workspace-fs-at/build/Release/workspace_fs_at.node"); const r=a.openat({parent:null,name:"/",kind:"directory",createMode:0}); a.fsyncDirectory(r.handle); a.close(r.handle); require("fs-ext")' EXPOSE 8787 HEALTHCHECK --interval=15s --timeout=3s --retries=5 --start-period=30s \ CMD curl -fsS http://127.0.0.1:8787/health || exit 1