cmd/sharefs: implement shared filesystem
All checks were successful
Test / Create distribution (push) Successful in 46s
Test / Sandbox (push) Successful in 2m40s
Test / Hakurei (push) Successful in 3m41s
Test / Hpkg (push) Successful in 4m42s
Test / Sandbox (race detector) (push) Successful in 4m53s
Test / Hakurei (race detector) (push) Successful in 5m53s
Test / ShareFS (push) Successful in 38m10s
Test / Flake checks (push) Successful in 1m46s
All checks were successful
Test / Create distribution (push) Successful in 46s
Test / Sandbox (push) Successful in 2m40s
Test / Hakurei (push) Successful in 3m41s
Test / Hpkg (push) Successful in 4m42s
Test / Sandbox (race detector) (push) Successful in 4m53s
Test / Hakurei (race detector) (push) Successful in 5m53s
Test / ShareFS (push) Successful in 38m10s
Test / Flake checks (push) Successful in 1m46s
This is for passing files between applications, similar to android /sdcard. Signed-off-by: Ophestra <cat@gensokyo.uk>
This commit is contained in:
@@ -72,6 +72,23 @@ jobs:
|
|||||||
path: result/*
|
path: result/*
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
|
sharefs:
|
||||||
|
name: ShareFS
|
||||||
|
runs-on: nix
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Run NixOS test
|
||||||
|
run: nix build --out-link "result" --print-out-paths --print-build-logs .#checks.x86_64-linux.sharefs
|
||||||
|
|
||||||
|
- name: Upload test output
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: "sharefs-vm-output"
|
||||||
|
path: result/*
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
hpkg:
|
hpkg:
|
||||||
name: Hpkg
|
name: Hpkg
|
||||||
runs-on: nix
|
runs-on: nix
|
||||||
@@ -96,6 +113,7 @@ jobs:
|
|||||||
- race
|
- race
|
||||||
- sandbox
|
- sandbox
|
||||||
- sandbox-race
|
- sandbox-race
|
||||||
|
- sharefs
|
||||||
- hpkg
|
- hpkg
|
||||||
runs-on: nix
|
runs-on: nix
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
316
cmd/sharefs/fuse-helper.c
Normal file
316
cmd/sharefs/fuse-helper.c
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
#ifndef _GNU_SOURCE
|
||||||
|
#define _GNU_SOURCE /* O_DIRECT */
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#include <dirent.h>
|
||||||
|
#include <errno.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
/* TODO(ophestra): remove after 05ce67fea99ca09cd4b6625cff7aec9cc222dd5a reaches a release */
|
||||||
|
#include <sys/syscall.h>
|
||||||
|
|
||||||
|
#include "fuse-helper.h"
|
||||||
|
#define SHAREFS_MEDIA_RW_ID (1 << 10) - 1 /* owning gid presented to userspace */
|
||||||
|
#define SHAREFS_PERM_DIR 0700 /* permission bits for directories presented to userspace */
|
||||||
|
#define SHAREFS_PERM_REG 0600 /* permission bits for regular files presented to userspace */
|
||||||
|
#define SHAREFS_FORBIDDEN_FLAGS O_DIRECT /* these open flags are cleared unconditionally */
|
||||||
|
|
||||||
|
/* translate_pathname translates a userspace pathname to a relative pathname;
|
||||||
|
* the returned address is a constant string or part of pathname, it is never heap allocated. */
|
||||||
|
static inline const char *translate_pathname(const char *pathname) {
|
||||||
|
if (pathname == NULL) {
|
||||||
|
errno = EINVAL;
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (*pathname == '/')
|
||||||
|
pathname++;
|
||||||
|
if (*pathname == '\0')
|
||||||
|
pathname = ".";
|
||||||
|
return pathname;
|
||||||
|
}
|
||||||
|
|
||||||
|
#define MUST_TRANSLATE_PATHNAME(pathname) \
|
||||||
|
do { \
|
||||||
|
pathname = translate_pathname(pathname); \
|
||||||
|
if (pathname == NULL) \
|
||||||
|
return -errno; \
|
||||||
|
} while (0)
|
||||||
|
|
||||||
|
/* GET_CONTEXT_PRIV obtains fuse context and private data for the calling thread. */
|
||||||
|
#define GET_CONTEXT_PRIV(ctx, priv) \
|
||||||
|
do { \
|
||||||
|
ctx = fuse_get_context(); \
|
||||||
|
priv = ctx->private_data; \
|
||||||
|
} while (0)
|
||||||
|
|
||||||
|
/* impl_getattr modifies a struct stat from the kernel to present to userspace;
|
||||||
|
* impl_getattr returns a negative errno style error code. */
|
||||||
|
static int impl_getattr(struct fuse_context *ctx, struct stat *statbuf) {
|
||||||
|
/* allowlist of permitted types */
|
||||||
|
if (!S_ISDIR(statbuf->st_mode) && !S_ISREG(statbuf->st_mode) && !S_ISLNK(statbuf->st_mode)) {
|
||||||
|
return -ENOTRECOVERABLE; /* returning an errno causes all operations on the file to return EIO */
|
||||||
|
}
|
||||||
|
|
||||||
|
#define OVERRIDE_PERM(v) (statbuf->st_mode & ~0777) | (v & 0777)
|
||||||
|
if (S_ISDIR(statbuf->st_mode))
|
||||||
|
statbuf->st_mode = OVERRIDE_PERM(SHAREFS_PERM_DIR);
|
||||||
|
else if (S_ISREG(statbuf->st_mode))
|
||||||
|
statbuf->st_mode = OVERRIDE_PERM(SHAREFS_PERM_REG);
|
||||||
|
else
|
||||||
|
statbuf->st_mode = 0; /* should always be symlink in this case */
|
||||||
|
|
||||||
|
statbuf->st_uid = ctx->uid;
|
||||||
|
statbuf->st_gid = SHAREFS_MEDIA_RW_ID;
|
||||||
|
statbuf->st_ctim = statbuf->st_mtim;
|
||||||
|
statbuf->st_nlink = 1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* fuse_operations implementation */
|
||||||
|
|
||||||
|
int sharefs_getattr(const char *pathname, struct stat *statbuf, struct fuse_file_info *fi) {
|
||||||
|
struct fuse_context *ctx;
|
||||||
|
struct sharefs_private *priv;
|
||||||
|
GET_CONTEXT_PRIV(ctx, priv);
|
||||||
|
MUST_TRANSLATE_PATHNAME(pathname);
|
||||||
|
|
||||||
|
(void)fi;
|
||||||
|
|
||||||
|
if (fstatat(priv->dirfd, pathname, statbuf, AT_SYMLINK_NOFOLLOW) == -1)
|
||||||
|
return -errno;
|
||||||
|
return impl_getattr(ctx, statbuf);
|
||||||
|
}
|
||||||
|
|
||||||
|
int sharefs_readlink(const char *pathname, char *buf, size_t bufsiz) {
|
||||||
|
int res;
|
||||||
|
struct fuse_context *ctx;
|
||||||
|
struct sharefs_private *priv;
|
||||||
|
GET_CONTEXT_PRIV(ctx, priv);
|
||||||
|
MUST_TRANSLATE_PATHNAME(pathname);
|
||||||
|
|
||||||
|
if ((res = readlinkat(priv->dirfd, pathname, buf, bufsiz - 1)) == -1)
|
||||||
|
return -errno;
|
||||||
|
buf[res] = '\0';
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int sharefs_readdir(const char *pathname, void *buf, fuse_fill_dir_t filler, off_t offset, struct fuse_file_info *fi, enum fuse_readdir_flags flags) {
|
||||||
|
int fd;
|
||||||
|
DIR *dp;
|
||||||
|
struct stat st;
|
||||||
|
int ret = 0;
|
||||||
|
struct dirent *de;
|
||||||
|
|
||||||
|
struct fuse_context *ctx;
|
||||||
|
struct sharefs_private *priv;
|
||||||
|
GET_CONTEXT_PRIV(ctx, priv);
|
||||||
|
MUST_TRANSLATE_PATHNAME(pathname);
|
||||||
|
|
||||||
|
(void)offset;
|
||||||
|
(void)fi;
|
||||||
|
|
||||||
|
if ((fd = openat(priv->dirfd, pathname, O_RDONLY | O_DIRECTORY | O_CLOEXEC)) == -1)
|
||||||
|
return -errno;
|
||||||
|
if ((dp = fdopendir(fd)) == NULL) {
|
||||||
|
close(fd);
|
||||||
|
return -errno;
|
||||||
|
}
|
||||||
|
|
||||||
|
errno = 0; /* for the next readdir call */
|
||||||
|
while ((de = readdir(dp)) != NULL) {
|
||||||
|
if (flags & FUSE_READDIR_PLUS) {
|
||||||
|
if (fstatat(dirfd(dp), de->d_name, &st, AT_SYMLINK_NOFOLLOW) == -1) {
|
||||||
|
ret = -errno;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((ret = impl_getattr(ctx, &st)) < 0)
|
||||||
|
break;
|
||||||
|
|
||||||
|
errno = 0;
|
||||||
|
ret = filler(buf, de->d_name, &st, 0, FUSE_FILL_DIR_PLUS);
|
||||||
|
} else
|
||||||
|
ret = filler(buf, de->d_name, NULL, 0, 0);
|
||||||
|
|
||||||
|
if (ret != 0) {
|
||||||
|
ret = errno != 0 ? -errno : -EIO; /* filler */
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
errno = 0; /* for the next readdir call */
|
||||||
|
}
|
||||||
|
if (ret == 0 && errno != 0)
|
||||||
|
ret = -errno; /* readdir */
|
||||||
|
|
||||||
|
closedir(dp);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
int sharefs_mkdir(const char *pathname, mode_t mode) {
|
||||||
|
struct fuse_context *ctx;
|
||||||
|
struct sharefs_private *priv;
|
||||||
|
GET_CONTEXT_PRIV(ctx, priv);
|
||||||
|
MUST_TRANSLATE_PATHNAME(pathname);
|
||||||
|
|
||||||
|
(void)mode;
|
||||||
|
|
||||||
|
if (mkdirat(priv->dirfd, pathname, SHAREFS_PERM_DIR) == -1)
|
||||||
|
return -errno;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int sharefs_unlink(const char *pathname) {
|
||||||
|
struct fuse_context *ctx;
|
||||||
|
struct sharefs_private *priv;
|
||||||
|
GET_CONTEXT_PRIV(ctx, priv);
|
||||||
|
MUST_TRANSLATE_PATHNAME(pathname);
|
||||||
|
|
||||||
|
if (unlinkat(priv->dirfd, pathname, 0) == -1)
|
||||||
|
return -errno;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int sharefs_rmdir(const char *pathname) {
|
||||||
|
struct fuse_context *ctx;
|
||||||
|
struct sharefs_private *priv;
|
||||||
|
GET_CONTEXT_PRIV(ctx, priv);
|
||||||
|
MUST_TRANSLATE_PATHNAME(pathname);
|
||||||
|
|
||||||
|
if (unlinkat(priv->dirfd, pathname, AT_REMOVEDIR) == -1)
|
||||||
|
return -errno;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int sharefs_rename(const char *oldpath, const char *newpath, unsigned int flags) {
|
||||||
|
int res;
|
||||||
|
|
||||||
|
struct fuse_context *ctx;
|
||||||
|
struct sharefs_private *priv;
|
||||||
|
GET_CONTEXT_PRIV(ctx, priv);
|
||||||
|
MUST_TRANSLATE_PATHNAME(oldpath);
|
||||||
|
MUST_TRANSLATE_PATHNAME(newpath);
|
||||||
|
|
||||||
|
/* TODO(ophestra): replace with wrapper after 05ce67fea99ca09cd4b6625cff7aec9cc222dd5a reaches a release */
|
||||||
|
if (syscall(__NR_renameat2, priv->dirfd, oldpath, priv->dirfd, newpath, flags) == -1)
|
||||||
|
return -errno;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int sharefs_truncate(const char *pathname, off_t length, struct fuse_file_info *fi) {
|
||||||
|
int fd;
|
||||||
|
int ret;
|
||||||
|
|
||||||
|
struct fuse_context *ctx;
|
||||||
|
struct sharefs_private *priv;
|
||||||
|
GET_CONTEXT_PRIV(ctx, priv);
|
||||||
|
MUST_TRANSLATE_PATHNAME(pathname);
|
||||||
|
|
||||||
|
(void)fi;
|
||||||
|
|
||||||
|
if ((fd = openat(priv->dirfd, pathname, O_WRONLY | O_CLOEXEC)) == -1)
|
||||||
|
return -errno;
|
||||||
|
if ((ret = ftruncate(fd, length)) == -1)
|
||||||
|
ret = -errno;
|
||||||
|
close(fd);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
int sharefs_utimens(const char *pathname, const struct timespec times[2], struct fuse_file_info *fi) {
|
||||||
|
int res;
|
||||||
|
|
||||||
|
struct fuse_context *ctx;
|
||||||
|
struct sharefs_private *priv;
|
||||||
|
GET_CONTEXT_PRIV(ctx, priv);
|
||||||
|
MUST_TRANSLATE_PATHNAME(pathname);
|
||||||
|
|
||||||
|
(void)fi;
|
||||||
|
|
||||||
|
if (utimensat(priv->dirfd, pathname, times, AT_SYMLINK_NOFOLLOW) == -1)
|
||||||
|
return -errno;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int sharefs_create(const char *pathname, mode_t mode, struct fuse_file_info *fi) {
|
||||||
|
int fd;
|
||||||
|
|
||||||
|
struct fuse_context *ctx;
|
||||||
|
struct sharefs_private *priv;
|
||||||
|
GET_CONTEXT_PRIV(ctx, priv);
|
||||||
|
MUST_TRANSLATE_PATHNAME(pathname);
|
||||||
|
|
||||||
|
(void)mode;
|
||||||
|
(void)fi;
|
||||||
|
|
||||||
|
if ((fd = openat(priv->dirfd, pathname, fi->flags & ~SHAREFS_FORBIDDEN_FLAGS, SHAREFS_PERM_REG)) == -1)
|
||||||
|
return -errno;
|
||||||
|
fi->fh = fd;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int sharefs_open(const char *pathname, struct fuse_file_info *fi) {
|
||||||
|
int fd;
|
||||||
|
|
||||||
|
struct fuse_context *ctx;
|
||||||
|
struct sharefs_private *priv;
|
||||||
|
GET_CONTEXT_PRIV(ctx, priv);
|
||||||
|
MUST_TRANSLATE_PATHNAME(pathname);
|
||||||
|
|
||||||
|
if ((fd = openat(priv->dirfd, pathname, fi->flags & ~SHAREFS_FORBIDDEN_FLAGS)) == -1)
|
||||||
|
return -errno;
|
||||||
|
fi->fh = fd;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int sharefs_read(const char *pathname, char *buf, size_t count, off_t offset, struct fuse_file_info *fi) {
|
||||||
|
int ret;
|
||||||
|
|
||||||
|
(void)pathname;
|
||||||
|
|
||||||
|
if ((ret = pread(fi->fh, buf, count, offset)) == -1)
|
||||||
|
return -errno;
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
int sharefs_write(const char *pathname, const char *buf, size_t count, off_t offset, struct fuse_file_info *fi) {
|
||||||
|
int ret;
|
||||||
|
|
||||||
|
(void)pathname;
|
||||||
|
|
||||||
|
if ((ret = pwrite(fi->fh, buf, count, offset)) == -1)
|
||||||
|
return -errno;
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
int sharefs_statfs(const char *pathname, struct statvfs *statbuf) {
|
||||||
|
int fd;
|
||||||
|
int ret;
|
||||||
|
|
||||||
|
struct fuse_context *ctx;
|
||||||
|
struct sharefs_private *priv;
|
||||||
|
GET_CONTEXT_PRIV(ctx, priv);
|
||||||
|
MUST_TRANSLATE_PATHNAME(pathname);
|
||||||
|
|
||||||
|
if ((fd = openat(priv->dirfd, pathname, O_RDONLY | O_CLOEXEC)) == -1)
|
||||||
|
return -errno;
|
||||||
|
if ((ret = fstatvfs(fd, statbuf)) == -1)
|
||||||
|
ret = -errno;
|
||||||
|
close(fd);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
int sharefs_release(const char *pathname, struct fuse_file_info *fi) {
|
||||||
|
(void)pathname;
|
||||||
|
|
||||||
|
return close(fi->fh);
|
||||||
|
}
|
||||||
|
|
||||||
|
int sharefs_fsync(const char *pathname, int datasync, struct fuse_file_info *fi) {
|
||||||
|
int res;
|
||||||
|
|
||||||
|
(void)pathname;
|
||||||
|
|
||||||
|
if (datasync ? fdatasync(fi->fh) : fsync(fi->fh) == -1)
|
||||||
|
return -errno;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
29
cmd/sharefs/fuse-helper.h
Normal file
29
cmd/sharefs/fuse-helper.h
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
#define FUSE_USE_VERSION FUSE_MAKE_VERSION(3, 4)
|
||||||
|
#include <fuse.h>
|
||||||
|
#include <fuse_lowlevel.h> /* for fuse_cmdline_help */
|
||||||
|
|
||||||
|
#if (FUSE_VERSION < FUSE_MAKE_VERSION(3, 4))
|
||||||
|
#error This package requires libfuse >= v3.4
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* sharefs_private is populated by sharefs_init and contains process-wide context */
|
||||||
|
struct sharefs_private {
|
||||||
|
int dirfd; /* source dirfd opened during sharefs_init */
|
||||||
|
};
|
||||||
|
|
||||||
|
int sharefs_getattr(const char *pathname, struct stat *statbuf, struct fuse_file_info *fi);
|
||||||
|
int sharefs_readlink(const char *pathname, char *buf, size_t bufsiz);
|
||||||
|
int sharefs_readdir(const char *pathname, void *buf, fuse_fill_dir_t filler, off_t offset, struct fuse_file_info *fi, enum fuse_readdir_flags flags);
|
||||||
|
int sharefs_mkdir(const char *pathname, mode_t mode);
|
||||||
|
int sharefs_unlink(const char *pathname);
|
||||||
|
int sharefs_rmdir(const char *pathname);
|
||||||
|
int sharefs_rename(const char *oldpath, const char *newpath, unsigned int flags);
|
||||||
|
int sharefs_truncate(const char *pathname, off_t length, struct fuse_file_info *fi);
|
||||||
|
int sharefs_utimens(const char *pathname, const struct timespec times[2], struct fuse_file_info *fi);
|
||||||
|
int sharefs_create(const char *pathname, mode_t mode, struct fuse_file_info *fi);
|
||||||
|
int sharefs_open(const char *pathname, struct fuse_file_info *fi);
|
||||||
|
int sharefs_read(const char *pathname, char *buf, size_t count, off_t offset, struct fuse_file_info *fi);
|
||||||
|
int sharefs_write(const char *pathname, const char *buf, size_t count, off_t offset, struct fuse_file_info *fi);
|
||||||
|
int sharefs_statfs(const char *pathname, struct statvfs *statbuf);
|
||||||
|
int sharefs_release(const char *pathname, struct fuse_file_info *fi);
|
||||||
|
int sharefs_fsync(const char *pathname, int datasync, struct fuse_file_info *fi);
|
||||||
265
cmd/sharefs/fuse.go
Normal file
265
cmd/sharefs/fuse.go
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
/*
|
||||||
|
#cgo pkg-config: --static fuse3
|
||||||
|
|
||||||
|
#include "fuse-helper.h"
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
extern void *sharefs_init(struct fuse_conn_info *conn, struct fuse_config *cfg);
|
||||||
|
void sharefs_destroy(void *private_data);
|
||||||
|
|
||||||
|
typedef void (*closure)();
|
||||||
|
static inline struct fuse_opt _FUSE_OPT_END() { return (struct fuse_opt)FUSE_OPT_END; };
|
||||||
|
static inline int _fuse_main(int argc, char *argv[], const struct fuse_operations *op, void *user_data) { return fuse_main(argc, argv, op, user_data); }
|
||||||
|
*/
|
||||||
|
import "C"
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
// closure represents a C function pointer.
|
||||||
|
closure = C.closure
|
||||||
|
|
||||||
|
// fuseArgs represents the fuse_args structure.
|
||||||
|
fuseArgs = C.struct_fuse_args
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// initFailed is set to true by sharefs_init if initialisation was unsuccessful.
|
||||||
|
initFailed bool
|
||||||
|
|
||||||
|
// initSource is pathname to the writable source directory used by sharefs_init to populate sharefs_private.
|
||||||
|
initSource string
|
||||||
|
// initSetUidGid is the uid and gid to set by sharefs_init when running as root.
|
||||||
|
initSetUidGid [2]int
|
||||||
|
)
|
||||||
|
|
||||||
|
//export sharefs_init
|
||||||
|
func sharefs_init(_ *C.struct_fuse_conn_info, cfg *C.struct_fuse_config) unsafe.Pointer {
|
||||||
|
private_data := C.malloc(C.size_t(unsafe.Sizeof(C.struct_sharefs_private{})))
|
||||||
|
priv := (*C.struct_sharefs_private)(private_data)
|
||||||
|
|
||||||
|
if os.Geteuid() == 0 {
|
||||||
|
if initSetUidGid[0] <= 0 || initSetUidGid[1] <= 0 {
|
||||||
|
log.Println("setuid and setgid must not be 0")
|
||||||
|
goto fail
|
||||||
|
}
|
||||||
|
if err := syscall.Setresgid(initSetUidGid[1], initSetUidGid[1], initSetUidGid[1]); err != nil {
|
||||||
|
log.Printf("cannot set gid: %v", err)
|
||||||
|
goto fail
|
||||||
|
}
|
||||||
|
if err := syscall.Setgroups(nil); err != nil {
|
||||||
|
log.Printf("cannot set supplementary groups: %v", err)
|
||||||
|
goto fail
|
||||||
|
}
|
||||||
|
if err := syscall.Setresuid(initSetUidGid[0], initSetUidGid[0], initSetUidGid[0]); err != nil {
|
||||||
|
log.Printf("cannot set uid: %v", err)
|
||||||
|
goto fail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.use_ino = C.true
|
||||||
|
cfg.direct_io = C.false
|
||||||
|
// getattr is context-dependent
|
||||||
|
cfg.attr_timeout = 0
|
||||||
|
cfg.entry_timeout = 0
|
||||||
|
cfg.negative_timeout = 0
|
||||||
|
|
||||||
|
// all future filesystem operations happen through this dirfd
|
||||||
|
if fd, err := syscall.Open(initSource, syscall.O_DIRECTORY|syscall.O_RDONLY, 0); err != nil {
|
||||||
|
log.Printf("cannot open %q: %v", initSource, err)
|
||||||
|
goto fail
|
||||||
|
} else if err = syscall.Fchdir(fd); err != nil {
|
||||||
|
log.Printf("cannot enter %q: %s", initSource, err)
|
||||||
|
goto fail
|
||||||
|
} else {
|
||||||
|
priv.dirfd = C.int(fd)
|
||||||
|
}
|
||||||
|
|
||||||
|
return private_data
|
||||||
|
|
||||||
|
fail:
|
||||||
|
C.free(private_data)
|
||||||
|
C.fuse_exit(C.fuse_get_context().fuse)
|
||||||
|
initFailed = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
//export sharefs_destroy
|
||||||
|
func sharefs_destroy(private_data unsafe.Pointer) {
|
||||||
|
if private_data != nil {
|
||||||
|
defer C.free(private_data)
|
||||||
|
priv := (*C.struct_sharefs_private)(private_data)
|
||||||
|
|
||||||
|
if err := syscall.Close(int(priv.dirfd)); err != nil {
|
||||||
|
log.Printf("cannot close source directory: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// showHelp prints the help message.
|
||||||
|
func showHelp() {
|
||||||
|
fmt.Printf("usage: %s [options] <mountpoint>\n\n", executableName)
|
||||||
|
C.fuse_cmdline_help()
|
||||||
|
C.fuse_lowlevel_help()
|
||||||
|
fmt.Println(" -o source=/data/media source directory to be mounted")
|
||||||
|
fmt.Println(" -o setuid=1023 uid to run as when starting as root")
|
||||||
|
fmt.Println(" -o setgid=1023 gid to run as when starting as root")
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseOpts parses fuse options via fuse_opt_parse.
|
||||||
|
func parseOpts(args *C.struct_fuse_args) (
|
||||||
|
source string,
|
||||||
|
setuid, setgid int,
|
||||||
|
ret int,
|
||||||
|
) {
|
||||||
|
var unsafeOpts struct {
|
||||||
|
// Pathname to writable source directory.
|
||||||
|
source *C.char
|
||||||
|
|
||||||
|
// Decimal string representation of uid to set when running as root.
|
||||||
|
setuid *C.char
|
||||||
|
// Decimal string representation of gid to set when running as root.
|
||||||
|
setgid *C.char
|
||||||
|
}
|
||||||
|
|
||||||
|
if C.fuse_opt_parse(args, unsafe.Pointer(&unsafeOpts), &[]C.struct_fuse_opt{
|
||||||
|
{templ: C.CString("source=%s"), offset: C.ulong(unsafe.Offsetof(unsafeOpts.source)), value: 0},
|
||||||
|
{templ: C.CString("setuid=%s"), offset: C.ulong(unsafe.Offsetof(unsafeOpts.setuid)), value: 0},
|
||||||
|
{templ: C.CString("setgid=%s"), offset: C.ulong(unsafe.Offsetof(unsafeOpts.setgid)), value: 0},
|
||||||
|
|
||||||
|
C._FUSE_OPT_END(),
|
||||||
|
}[0], nil) == -1 {
|
||||||
|
ret = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if unsafeOpts.source != nil {
|
||||||
|
defer C.free(unsafe.Pointer(unsafeOpts.source))
|
||||||
|
}
|
||||||
|
if unsafeOpts.setuid != nil {
|
||||||
|
defer C.free(unsafe.Pointer(unsafeOpts.setuid))
|
||||||
|
}
|
||||||
|
if unsafeOpts.setgid != nil {
|
||||||
|
defer C.free(unsafe.Pointer(unsafeOpts.setgid))
|
||||||
|
}
|
||||||
|
|
||||||
|
if unsafeOpts.source == nil {
|
||||||
|
showHelp()
|
||||||
|
ret = 1
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
source = C.GoString(unsafeOpts.source)
|
||||||
|
}
|
||||||
|
|
||||||
|
if unsafeOpts.setuid == nil {
|
||||||
|
setuid = -1
|
||||||
|
} else if v, err := strconv.Atoi(C.GoString(unsafeOpts.setuid)); err != nil || v <= 0 {
|
||||||
|
log.Println("invalid value for option setuid")
|
||||||
|
ret = 1
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
setuid = v
|
||||||
|
}
|
||||||
|
if unsafeOpts.setgid == nil {
|
||||||
|
setgid = -1
|
||||||
|
} else if v, err := strconv.Atoi(C.GoString(unsafeOpts.setgid)); err != nil || v <= 0 {
|
||||||
|
log.Println("invalid value for option setgid")
|
||||||
|
ret = 1
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
setgid = v
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyStrings returns a copy of s with null-termination.
|
||||||
|
func copyStrings(s ...string) **C.char {
|
||||||
|
if len(s) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
args := unsafe.Slice((**C.char)(C.malloc(C.size_t(uintptr(len(s))*unsafe.Sizeof(s[0])))), len(s))
|
||||||
|
for i, arg := range s {
|
||||||
|
args[i] = C.CString(arg)
|
||||||
|
}
|
||||||
|
return &args[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// unsafeAddArgument adds an argument to fuseArgs via fuse_opt_add_arg.
|
||||||
|
// The last byte of arg must be 0.
|
||||||
|
func unsafeAddArgument(args *fuseArgs, arg string) {
|
||||||
|
C.fuse_opt_add_arg(args, (*C.char)(unsafe.Pointer(unsafe.StringData(arg))))
|
||||||
|
}
|
||||||
|
|
||||||
|
func _main(argc int, argv **C.char) int {
|
||||||
|
args := C.struct_fuse_args{argc: C.int(argc), argv: argv, allocated: 1}
|
||||||
|
|
||||||
|
// this causes the kernel to enforce access control based on
|
||||||
|
// struct stat populated by sharefs_getattr
|
||||||
|
unsafeAddArgument(&args, "-odefault_permissions\x00")
|
||||||
|
|
||||||
|
{
|
||||||
|
source, setuid, setgid, ret := parseOpts(&args)
|
||||||
|
if ret != 0 {
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
if a, err := filepath.Abs(source); err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
return 1
|
||||||
|
} else {
|
||||||
|
initSource = a
|
||||||
|
}
|
||||||
|
|
||||||
|
if os.Geteuid() == 0 {
|
||||||
|
if setuid <= 0 || setgid <= 0 {
|
||||||
|
log.Println("setuid and setgid must not be 0")
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
} else if setuid > 0 || setgid > 0 {
|
||||||
|
log.Println("setuid and setgid has no effect when not starting as root")
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
initSetUidGid[0], initSetUidGid[1] = setuid, setgid
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(ophestra): spawn container here, set PR_SET_NO_NEW_PRIVS and enforce landlock
|
||||||
|
fuse_main_return := C._fuse_main(args.argc, args.argv, &C.struct_fuse_operations{
|
||||||
|
init: closure(C.sharefs_init),
|
||||||
|
destroy: closure(C.sharefs_destroy),
|
||||||
|
|
||||||
|
// implemented in fuse-helper.c
|
||||||
|
getattr: closure(C.sharefs_getattr),
|
||||||
|
readlink: closure(C.sharefs_readlink),
|
||||||
|
readdir: closure(C.sharefs_readdir),
|
||||||
|
mkdir: closure(C.sharefs_mkdir),
|
||||||
|
unlink: closure(C.sharefs_unlink),
|
||||||
|
rmdir: closure(C.sharefs_rmdir),
|
||||||
|
rename: closure(C.sharefs_rename),
|
||||||
|
truncate: closure(C.sharefs_truncate),
|
||||||
|
utimens: closure(C.sharefs_utimens),
|
||||||
|
create: closure(C.sharefs_create),
|
||||||
|
open: closure(C.sharefs_open),
|
||||||
|
read: closure(C.sharefs_read),
|
||||||
|
write: closure(C.sharefs_write),
|
||||||
|
statfs: closure(C.sharefs_statfs),
|
||||||
|
release: closure(C.sharefs_release),
|
||||||
|
fsync: closure(C.sharefs_fsync),
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
if initFailed {
|
||||||
|
return 1
|
||||||
|
} else {
|
||||||
|
return int(fuse_main_return)
|
||||||
|
}
|
||||||
|
}
|
||||||
31
cmd/sharefs/main.go
Normal file
31
cmd/sharefs/main.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"runtime"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// executableName is the [path.Base] name of the executable that started the current process.
|
||||||
|
var executableName = func() string {
|
||||||
|
if len(os.Args) > 0 {
|
||||||
|
return path.Base(os.Args[0])
|
||||||
|
} else if name, err := os.Executable(); err != nil {
|
||||||
|
return "sharefs"
|
||||||
|
} else {
|
||||||
|
return path.Base(name)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
runtime.LockOSThread()
|
||||||
|
log.SetFlags(0)
|
||||||
|
log.SetPrefix(executableName + ": ")
|
||||||
|
|
||||||
|
// don't mask creation mode, kernel already did that
|
||||||
|
syscall.Umask(0)
|
||||||
|
|
||||||
|
os.Exit(_main(len(os.Args), copyStrings(os.Args...)))
|
||||||
|
}
|
||||||
39
cmd/sharefs/test/configuration.nix
Normal file
39
cmd/sharefs/test/configuration.nix
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{ pkgs, ... }:
|
||||||
|
{
|
||||||
|
users.users = {
|
||||||
|
alice = {
|
||||||
|
isNormalUser = true;
|
||||||
|
description = "Alice Foobar";
|
||||||
|
password = "foobar";
|
||||||
|
uid = 1000;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
home-manager.users.alice.home.stateVersion = "24.11";
|
||||||
|
|
||||||
|
# Automatically login on tty1 as a normal user:
|
||||||
|
services.getty.autologinUser = "alice";
|
||||||
|
|
||||||
|
# For benchmarking sharefs:
|
||||||
|
environment.systemPackages = [ pkgs.fsmark ];
|
||||||
|
|
||||||
|
virtualisation = {
|
||||||
|
diskSize = 6 * 1024;
|
||||||
|
|
||||||
|
qemu.options = [
|
||||||
|
# Increase test performance:
|
||||||
|
"-smp 8"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
environment.hakurei = rec {
|
||||||
|
enable = true;
|
||||||
|
stateDir = "/var/lib/hakurei";
|
||||||
|
sharefs.source = "${stateDir}/sdcard";
|
||||||
|
users.alice = 0;
|
||||||
|
|
||||||
|
extraHomeConfig = {
|
||||||
|
home.stateVersion = "23.05";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
44
cmd/sharefs/test/default.nix
Normal file
44
cmd/sharefs/test/default.nix
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
testers,
|
||||||
|
|
||||||
|
system,
|
||||||
|
self,
|
||||||
|
}:
|
||||||
|
testers.nixosTest {
|
||||||
|
name = "sharefs";
|
||||||
|
nodes.machine =
|
||||||
|
{ options, pkgs, ... }:
|
||||||
|
let
|
||||||
|
fhs =
|
||||||
|
let
|
||||||
|
hakurei = options.environment.hakurei.package.default;
|
||||||
|
in
|
||||||
|
pkgs.buildFHSEnv {
|
||||||
|
pname = "hakurei-fhs";
|
||||||
|
inherit (hakurei) version;
|
||||||
|
targetPkgs = _: hakurei.targetPkgs;
|
||||||
|
extraOutputsToInstall = [ "dev" ];
|
||||||
|
profile = ''
|
||||||
|
export PKG_CONFIG_PATH="/usr/share/pkgconfig:$PKG_CONFIG_PATH"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
environment.systemPackages = [
|
||||||
|
# For go tests:
|
||||||
|
(pkgs.writeShellScriptBin "sharefs-workload-hakurei-tests" ''
|
||||||
|
cp -r "${self.packages.${system}.hakurei.src}" "/sdcard/hakurei" && cd "/sdcard/hakurei"
|
||||||
|
${fhs}/bin/hakurei-fhs -c 'CC="clang -O3 -Werror" go test ./...'
|
||||||
|
'')
|
||||||
|
];
|
||||||
|
|
||||||
|
imports = [
|
||||||
|
./configuration.nix
|
||||||
|
|
||||||
|
self.nixosModules.hakurei
|
||||||
|
self.inputs.home-manager.nixosModules.home-manager
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
testScript = builtins.readFile ./test.py;
|
||||||
|
}
|
||||||
14
cmd/sharefs/test/test.py
Normal file
14
cmd/sharefs/test/test.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
start_all()
|
||||||
|
machine.wait_for_unit("multi-user.target")
|
||||||
|
|
||||||
|
# Benchmark sharefs:
|
||||||
|
machine.succeed("fs_mark -v -d /sdcard/fs_mark -l /tmp/fs_log.txt")
|
||||||
|
machine.copy_from_vm("/tmp/fs_log.txt", "")
|
||||||
|
|
||||||
|
# Check permissions:
|
||||||
|
machine.succeed("ls /var/lib/hakurei/sdcard/fs_mark")
|
||||||
|
machine.succeed("sudo -u alice rm -rf /sdcard/fs_mark")
|
||||||
|
machine.fail("ls /var/lib/hakurei/sdcard/fs_mark")
|
||||||
|
|
||||||
|
# Run hakurei tests on sharefs:
|
||||||
|
machine.succeed("sudo -u alice sharefs-workload-hakurei-tests")
|
||||||
@@ -69,6 +69,8 @@
|
|||||||
withRace = true;
|
withRace = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
sharefs = callPackage ./cmd/sharefs/test { inherit system self; };
|
||||||
|
|
||||||
hpkg = callPackage ./cmd/hpkg/test { inherit system self; };
|
hpkg = callPackage ./cmd/hpkg/test { inherit system self; };
|
||||||
|
|
||||||
formatting = runCommandLocal "check-formatting" { nativeBuildInputs = [ nixfmt-rfc-style ]; } ''
|
formatting = runCommandLocal "check-formatting" { nativeBuildInputs = [ nixfmt-rfc-style ]; } ''
|
||||||
|
|||||||
72
nixos.nix
72
nixos.nix
@@ -66,6 +66,38 @@ in
|
|||||||
) "" cfg.users;
|
) "" cfg.users;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
systemd.services = {
|
||||||
|
sharefs = mkIf (cfg.sharefs.source != null) {
|
||||||
|
unitConfig.RequiresMountsFor = cfg.sharefs.source;
|
||||||
|
serviceConfig = {
|
||||||
|
NoNewPrivileges = true;
|
||||||
|
};
|
||||||
|
script = ''
|
||||||
|
${pkgs.coreutils}/bin/install \
|
||||||
|
-dm0700 \
|
||||||
|
-o ${cfg.sharefs.user} \
|
||||||
|
-g ${cfg.sharefs.group} \
|
||||||
|
${cfg.sharefs.source} ${cfg.sharefs.name}
|
||||||
|
|
||||||
|
exec ${cfg.package}/libexec/sharefs -f \
|
||||||
|
-o ${
|
||||||
|
lib.join "," [
|
||||||
|
"noexec"
|
||||||
|
"nosuid"
|
||||||
|
"nodev"
|
||||||
|
"auto_unmount"
|
||||||
|
"allow_other"
|
||||||
|
"clone_fd"
|
||||||
|
"setuid=$(id -u ${cfg.sharefs.user})"
|
||||||
|
"setgid=$(id -g ${cfg.sharefs.group})"
|
||||||
|
"source=${cfg.sharefs.source}"
|
||||||
|
]
|
||||||
|
} ${cfg.sharefs.name}
|
||||||
|
'';
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
home-manager =
|
home-manager =
|
||||||
let
|
let
|
||||||
privPackages = mapAttrs (_: userid: {
|
privPackages = mapAttrs (_: userid: {
|
||||||
@@ -322,25 +354,57 @@ in
|
|||||||
in
|
in
|
||||||
{
|
{
|
||||||
users = mkMerge (
|
users = mkMerge (
|
||||||
foldlAttrs (
|
foldlAttrs
|
||||||
|
(
|
||||||
acc: _: fid:
|
acc: _: fid:
|
||||||
acc
|
acc
|
||||||
++ foldlAttrs (
|
++ foldlAttrs (
|
||||||
acc': _: app:
|
acc': _: app:
|
||||||
acc' ++ [ { ${getsubname fid app.identity} = getuser fid app.identity; } ]
|
acc' ++ [ { ${getsubname fid app.identity} = getuser fid app.identity; } ]
|
||||||
) [ { ${getsubname fid 0} = getuser fid 0; } ] cfg.apps
|
) [ { ${getsubname fid 0} = getuser fid 0; } ] cfg.apps
|
||||||
) [ ] cfg.users
|
)
|
||||||
|
(
|
||||||
|
if (cfg.sharefs.source != null) then
|
||||||
|
[
|
||||||
|
{
|
||||||
|
${cfg.sharefs.user} = {
|
||||||
|
uid = lib.mkDefault 1023;
|
||||||
|
inherit (cfg.sharefs) group;
|
||||||
|
isSystemUser = true;
|
||||||
|
home = cfg.sharefs.source;
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
|
]
|
||||||
|
else
|
||||||
|
[ ]
|
||||||
|
)
|
||||||
|
cfg.users
|
||||||
);
|
);
|
||||||
|
|
||||||
groups = mkMerge (
|
groups = mkMerge (
|
||||||
foldlAttrs (
|
foldlAttrs
|
||||||
|
(
|
||||||
acc: _: fid:
|
acc: _: fid:
|
||||||
acc
|
acc
|
||||||
++ foldlAttrs (
|
++ foldlAttrs (
|
||||||
acc': _: app:
|
acc': _: app:
|
||||||
acc' ++ [ { ${getsubname fid app.identity} = getgroup fid app.identity; } ]
|
acc' ++ [ { ${getsubname fid app.identity} = getgroup fid app.identity; } ]
|
||||||
) [ { ${getsubname fid 0} = getgroup fid 0; } ] cfg.apps
|
) [ { ${getsubname fid 0} = getgroup fid 0; } ] cfg.apps
|
||||||
) [ ] cfg.users
|
)
|
||||||
|
(
|
||||||
|
if (cfg.sharefs.source != null) then
|
||||||
|
[
|
||||||
|
{
|
||||||
|
${cfg.sharefs.group} = {
|
||||||
|
gid = lib.mkDefault 1023;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
]
|
||||||
|
else
|
||||||
|
[ ]
|
||||||
|
)
|
||||||
|
cfg.users
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
34
options.nix
34
options.nix
@@ -40,6 +40,40 @@ in
|
|||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
sharefs = {
|
||||||
|
user = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "sharefs";
|
||||||
|
description = ''
|
||||||
|
Name of the user to run the sharefs daemon as.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
group = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "sharefs";
|
||||||
|
description = ''
|
||||||
|
Name of the group to run the sharefs daemon as.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
name = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "/sdcard";
|
||||||
|
description = ''
|
||||||
|
Host path to mount sharefs on.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
source = mkOption {
|
||||||
|
type = types.nullOr types.str;
|
||||||
|
default = null;
|
||||||
|
description = ''
|
||||||
|
Writable backing directory. Setting this to null disables sharefs.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
apps = mkOption {
|
apps = mkOption {
|
||||||
type =
|
type =
|
||||||
let
|
let
|
||||||
|
|||||||
@@ -13,6 +13,9 @@
|
|||||||
wayland-scanner,
|
wayland-scanner,
|
||||||
xorg,
|
xorg,
|
||||||
|
|
||||||
|
# for sharefs
|
||||||
|
fuse3,
|
||||||
|
|
||||||
# for hpkg
|
# for hpkg
|
||||||
zstd,
|
zstd,
|
||||||
gnutar,
|
gnutar,
|
||||||
@@ -92,6 +95,7 @@ buildGoModule rec {
|
|||||||
buildInputs = [
|
buildInputs = [
|
||||||
libffi
|
libffi
|
||||||
libseccomp
|
libseccomp
|
||||||
|
fuse3
|
||||||
acl
|
acl
|
||||||
wayland
|
wayland
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
{ pkgs, ... }:
|
{ pkgs, ... }:
|
||||||
{
|
{
|
||||||
environment.hakurei = {
|
environment.hakurei = rec {
|
||||||
enable = true;
|
enable = true;
|
||||||
stateDir = "/var/lib/hakurei";
|
stateDir = "/var/lib/hakurei";
|
||||||
|
sharefs.source = "${stateDir}/sdcard";
|
||||||
users.alice = 0;
|
users.alice = 0;
|
||||||
apps = {
|
apps = {
|
||||||
"cat.gensokyo.extern.foot.noEnablements" = {
|
"cat.gensokyo.extern.foot.noEnablements" = {
|
||||||
|
|||||||
Reference in New Issue
Block a user