26 Commits

Author SHA1 Message Date
7ad8f15030 internal/pkg: implement caching for files
All checks were successful
Test / Create distribution (push) Successful in 46s
Test / Sandbox (push) Successful in 2m30s
Test / ShareFS (push) Successful in 3m34s
Test / Sandbox (race detector) (push) Successful in 4m42s
Test / Hpkg (push) Successful in 4m22s
Test / Hakurei (race detector) (push) Successful in 3m15s
Test / Hakurei (push) Successful in 2m28s
Test / Flake checks (push) Successful in 1m39s
This change contains primitives for validating and caching single-file artifacts.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-01-02 00:59:37 +09:00
993afde840 dist: install sharefs
All checks were successful
Test / Create distribution (push) Successful in 1m3s
Test / Sandbox (push) Successful in 2m48s
Test / Hakurei (push) Successful in 3m58s
Test / ShareFS (push) Successful in 3m53s
Test / Hpkg (push) Successful in 4m39s
Test / Sandbox (race detector) (push) Successful in 5m0s
Test / Flake checks (push) Successful in 1m49s
Test / Hakurei (race detector) (push) Successful in 6m1s
This also removes the deprecated hpkg program.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-01-02 00:57:51 +09:00
c9cd16fd2a cmd/sharefs: prepare directory early
All checks were successful
Test / Create distribution (push) Successful in 38s
Test / ShareFS (push) Successful in 43s
Test / Sandbox (race detector) (push) Successful in 47s
Test / Sandbox (push) Successful in 49s
Test / Hpkg (push) Successful in 50s
Test / Hakurei (race detector) (push) Successful in 55s
Test / Hakurei (push) Successful in 58s
Test / Flake checks (push) Successful in 1m41s
This change also checks against filesystem daemon running as root early.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-27 23:17:02 +09:00
e42ea32dbe nix: configure sharefs via fileSystems
All checks were successful
Test / ShareFS (push) Successful in 41s
Test / Sandbox (race detector) (push) Successful in 45s
Test / Create distribution (push) Successful in 42s
Test / Sandbox (push) Successful in 47s
Test / Hpkg (push) Successful in 50s
Test / Hakurei (push) Successful in 56s
Test / Hakurei (race detector) (push) Successful in 56s
Test / Flake checks (push) Successful in 1m35s
Turns out this did not work because in the vm test harness, virtualisation.fileSystems completely and silently overrides fileSystems, causing its contents to not even be evaluated anymore. This is not documented as far as I can tell, and is not obvious by any stretch of the imagination. The current hack is cargo culted from nix-community/impermanence and hopefully lasts until this project fully replaces nix.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-27 23:14:08 +09:00
e7982b4ee9 cmd/sharefs: create directory as root
All checks were successful
Test / Create distribution (push) Successful in 42s
Test / Sandbox (push) Successful in 2m20s
Test / Hakurei (push) Successful in 3m30s
Test / Sandbox (race detector) (push) Successful in 4m42s
Test / Flake checks (push) Successful in 1m36s
Test / ShareFS (push) Successful in 3m26s
Test / Hpkg (push) Successful in 4m19s
Test / Hakurei (race detector) (push) Successful in 5m30s
This optional behaviour is required on NixOS as it is otherwise impossible to set this up: systemd.mounts breaks startup order somehow even though my unit looks identical to generated ones, fileSystems does not support any kind of initialisation or ordering other than against other mount points.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-27 22:14:33 +09:00
ef1ebf12d9 cmd/sharefs: handle mount -t fuse.sharefs
All checks were successful
Test / Create distribution (push) Successful in 33s
Test / ShareFS (push) Successful in 39s
Test / Sandbox (push) Successful in 46s
Test / Sandbox (race detector) (push) Successful in 45s
Test / Hpkg (push) Successful in 49s
Test / Hakurei (push) Successful in 54s
Test / Hakurei (race detector) (push) Successful in 55s
Test / Flake checks (push) Successful in 1m35s
This should have been handled in a custom option parsing function, but that much extra complexity is unnecessary for this edge case. Honestly I do not know why libfuse does not handle this itself.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-27 20:49:27 +09:00
775a9f57c9 cmd/sharefs: check option parsing behaviour
All checks were successful
Test / Create distribution (push) Successful in 44s
Test / ShareFS (push) Successful in 39s
Test / Sandbox (push) Successful in 47s
Test / Sandbox (race detector) (push) Successful in 46s
Test / Hakurei (race detector) (push) Successful in 54s
Test / Hpkg (push) Successful in 50s
Test / Hakurei (push) Successful in 55s
Test / Flake checks (push) Successful in 1m35s
This change makes it possible to check parseOpts behaviour as part of Go tests.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-27 17:33:12 +09:00
2f8ca83376 cmd/sharefs: containerise filesystem daemon
All checks were successful
Test / Create distribution (push) Successful in 44s
Test / Sandbox (push) Successful in 2m30s
Test / Hakurei (push) Successful in 3m26s
Test / ShareFS (push) Successful in 3m26s
Test / Hpkg (push) Successful in 4m20s
Test / Sandbox (race detector) (push) Successful in 4m41s
Test / Hakurei (race detector) (push) Successful in 5m31s
Test / Flake checks (push) Successful in 1m36s
This replaces the forking daemonise libfuse function which prevents Go callbacks from calling into the runtime. This also enforces least privilege on the daemon process.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-27 10:16:35 +09:00
3d720ada92 container: optionally allow orphan
All checks were successful
Test / Create distribution (push) Successful in 43s
Test / Sandbox (push) Successful in 2m21s
Test / ShareFS (push) Successful in 3m25s
Test / Hakurei (push) Successful in 3m31s
Test / Sandbox (race detector) (push) Successful in 4m37s
Test / Hpkg (push) Successful in 4m26s
Test / Hakurei (race detector) (push) Successful in 3m16s
Test / Flake checks (push) Successful in 1m45s
This is required for the typical daemonise use case.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-27 09:12:02 +09:00
2e5362e536 cmd/sharefs: opaque setup state
All checks were successful
Test / Sandbox (push) Successful in 2m26s
Test / Hakurei (push) Successful in 3m28s
Test / ShareFS (push) Successful in 3m31s
Test / Hpkg (push) Successful in 4m26s
Test / Sandbox (race detector) (push) Successful in 4m38s
Test / Hakurei (race detector) (push) Successful in 5m34s
Test / Flake checks (push) Successful in 1m42s
Test / Create distribution (push) Successful in 43s
This allows unrestricted use of the type system and prepares setup code for cross-process initialisation.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-27 04:14:00 +09:00
6d3bd27220 cmd/sharefs: expand fuse_main
All checks were successful
Test / Create distribution (push) Successful in 42s
Test / Sandbox (push) Successful in 2m20s
Test / ShareFS (push) Successful in 3m22s
Test / Hpkg (push) Successful in 4m20s
Test / Sandbox (race detector) (push) Successful in 4m34s
Test / Hakurei (race detector) (push) Successful in 5m28s
Test / Hakurei (push) Successful in 2m41s
Test / Flake checks (push) Successful in 1m55s
This change should not change behaviour other than making output more consistent.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-27 02:30:28 +09:00
a27305cb4a cmd/sharefs: improve help message
All checks were successful
Test / Create distribution (push) Successful in 42s
Test / Sandbox (push) Successful in 2m20s
Test / Hakurei (push) Successful in 3m24s
Test / ShareFS (push) Successful in 3m23s
Test / Hpkg (push) Successful in 4m19s
Test / Sandbox (race detector) (push) Successful in 4m36s
Test / Hakurei (race detector) (push) Successful in 5m32s
Test / Flake checks (push) Successful in 1m52s
This improves consistency with the fuse_main help message.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-27 02:20:41 +09:00
0e476c5e5b cmd/sharefs: allocate sharefs_private early
All checks were successful
Test / Create distribution (push) Successful in 43s
Test / Sandbox (push) Successful in 2m21s
Test / ShareFS (push) Successful in 3m26s
Test / Hpkg (push) Successful in 4m14s
Test / Sandbox (race detector) (push) Successful in 4m31s
Test / Hakurei (race detector) (push) Successful in 5m31s
Test / Hakurei (push) Successful in 2m34s
Test / Flake checks (push) Successful in 1m39s
This also removes global state used by sharefs_init.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-26 08:08:41 +09:00
54712e0426 nix: set noatime on sharefs
All checks were successful
Test / Create distribution (push) Successful in 42s
Test / Sandbox (push) Successful in 47s
Test / Sandbox (race detector) (push) Successful in 46s
Test / Hakurei (push) Successful in 55s
Test / Hpkg (push) Successful in 50s
Test / Hakurei (race detector) (push) Successful in 55s
Test / ShareFS (push) Successful in 2m27s
Test / Flake checks (push) Successful in 1m38s
Could improve performance, atime is not useful for this filesystem anyway.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-26 05:34:05 +09:00
b77c1ecfdb cmd/sharefs/test: check option handling
All checks were successful
Test / Sandbox (push) Successful in 46s
Test / Create distribution (push) Successful in 42s
Test / Sandbox (race detector) (push) Successful in 46s
Test / Hpkg (push) Successful in 50s
Test / Hakurei (push) Successful in 55s
Test / Hakurei (race detector) (push) Successful in 56s
Test / ShareFS (push) Successful in 2m27s
Test / Flake checks (push) Successful in 1m43s
This verifies behaviour related to setuid/setgid when starting as root.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-26 05:28:45 +09:00
dce5839a79 nix: do not restart sharefs
All checks were successful
Test / Create distribution (push) Successful in 43s
Test / Sandbox (push) Successful in 47s
Test / Sandbox (race detector) (push) Successful in 47s
Test / Hpkg (push) Successful in 50s
Test / Hakurei (race detector) (push) Successful in 54s
Test / Hakurei (push) Successful in 56s
Test / ShareFS (push) Successful in 2m29s
Test / Flake checks (push) Successful in 1m44s
This avoids disrupting running containers.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-26 04:12:14 +09:00
d597592e1f cmd/sharefs: rename fuse-helper to fuse-operations
All checks were successful
Test / Create distribution (push) Successful in 43s
Test / Sandbox (push) Successful in 2m34s
Test / Hakurei (push) Successful in 3m26s
Test / ShareFS (push) Successful in 3m23s
Test / Hpkg (push) Successful in 4m23s
Test / Sandbox (race detector) (push) Successful in 4m38s
Test / Hakurei (race detector) (push) Successful in 5m33s
Test / Flake checks (push) Successful in 1m45s
This is not really just library wrapper functions, but instead implements the callbacks, so fuse-operations makes more sense.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-26 03:19:32 +09:00
056f5b12d4 cmd/sharefs: move translate_pathname body to macro wrapper
All checks were successful
Test / Create distribution (push) Successful in 45s
Test / Sandbox (push) Successful in 2m26s
Test / Hakurei (push) Successful in 3m29s
Test / ShareFS (push) Successful in 3m26s
Test / Hpkg (push) Successful in 4m20s
Test / Sandbox (race detector) (push) Successful in 4m50s
Test / Hakurei (race detector) (push) Successful in 5m39s
Test / Flake checks (push) Successful in 1m45s
This is never called directly anywhere and it is simple enough to be included in the macro. This avoids passing the pointer around and dereferencing errno location, resulting in over 5% increase in throughput on the clang build. No change in the gcc build though.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-26 02:10:59 +09:00
da2bb546ba cmd/sharefs: remove readlink
All checks were successful
Test / Create distribution (push) Successful in 44s
Test / Sandbox (push) Successful in 2m31s
Test / ShareFS (push) Successful in 3m23s
Test / Hakurei (push) Successful in 3m27s
Test / Hpkg (push) Successful in 4m20s
Test / Sandbox (race detector) (push) Successful in 4m40s
Test / Hakurei (race detector) (push) Successful in 5m33s
Test / Flake checks (push) Successful in 1m44s
This filesystem does not support symbolic links, so readlink is not useful, and unreachable in this case because of the check in getattr.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-25 06:00:58 +09:00
7bfbd59810 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
This is for passing files between applications, similar to android /sdcard.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-25 05:13:02 +09:00
ea815a59e8 nix: disable source fortification in devShell
All checks were successful
Test / Create distribution (push) Successful in 35s
Test / Sandbox (push) Successful in 43s
Test / Sandbox (race detector) (push) Successful in 42s
Test / Hakurei (race detector) (push) Successful in 46s
Test / Hpkg (push) Successful in 44s
Test / Hakurei (push) Successful in 49s
Test / Flake checks (push) Successful in 1m36s
This generates warnings when compiling without optimisation.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-21 02:22:28 +09:00
28a8dc67d2 internal/pipewire: raise Core::Sync timeout
All checks were successful
Test / Create distribution (push) Successful in 38s
Test / Sandbox (push) Successful in 2m27s
Test / Hakurei (push) Successful in 3m24s
Test / Hpkg (push) Successful in 4m12s
Test / Sandbox (race detector) (push) Successful in 4m40s
Test / Hakurei (race detector) (push) Successful in 5m26s
Test / Flake checks (push) Successful in 1m35s
Hopefully relieves spurious failures on a very overloaded system.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-19 00:49:33 +09:00
ec49c63c5f internal/pipewire: EPOLL_CTL_ADD instead of EPOLL_CTL_MOD
All checks were successful
Test / Create distribution (push) Successful in 37s
Test / Sandbox (push) Successful in 2m29s
Test / Hakurei (push) Successful in 3m24s
Test / Hpkg (push) Successful in 4m15s
Test / Sandbox (race detector) (push) Successful in 4m30s
Test / Hakurei (race detector) (push) Successful in 5m26s
Test / Flake checks (push) Successful in 1m45s
Implementation is no longer tied down by the limitations of SyscallConn.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-19 00:43:44 +09:00
5a50bf80ee internal/pipewire: hold socket fd directly
All checks were successful
Test / Create distribution (push) Successful in 38s
Test / Sandbox (push) Successful in 2m39s
Test / Hakurei (push) Successful in 3m31s
Test / Sandbox (race detector) (push) Successful in 4m30s
Test / Hpkg (push) Successful in 4m34s
Test / Hakurei (race detector) (push) Successful in 5m29s
Test / Flake checks (push) Successful in 1m40s
The interface provided by net is not used here and is a leftover from a previous implementation. This change removes it.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-19 00:28:24 +09:00
ce06b7b663 internal/pipewire: inform conn of blocking intent
All checks were successful
Test / Create distribution (push) Successful in 30s
Test / Sandbox (push) Successful in 2m31s
Test / Hakurei (push) Successful in 3m29s
Test / Hpkg (push) Successful in 4m24s
Test / Sandbox (race detector) (push) Successful in 4m30s
Test / Hakurei (race detector) (push) Successful in 5m27s
Test / Flake checks (push) Successful in 1m40s
The interface does not expose underlying kernel notification mechanisms. This change removes the need to poll in situations were the next call might block.

This is made cumbersome by the SyscallConn interface left over from a previous implementation, it will be replaced in a later commit as the current implementation does not make use of any net.Conn methods other than Close.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-19 00:00:33 +09:00
08bdc68f3a internal/pipewire: sendmsg/recvmsg errors are fatal
All checks were successful
Test / Create distribution (push) Successful in 40s
Test / Sandbox (push) Successful in 2m35s
Test / Hakurei (push) Successful in 3m31s
Test / Hpkg (push) Successful in 4m20s
Test / Sandbox (race detector) (push) Successful in 4m42s
Test / Hakurei (race detector) (push) Successful in 5m28s
Test / Flake checks (push) Successful in 1m35s
When returned wrapped as a syscall error, these are impossible to recover from, so wrap them as a fatal error.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-18 23:33:12 +09:00
24 changed files with 2276 additions and 55 deletions

View File

@@ -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:

View File

@@ -0,0 +1,282 @@
#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-operations.h"
/* MUST_TRANSLATE_PATHNAME translates a userspace pathname to a relative pathname;
* the resulting address points to a constant string or part of pathname, it is never heap allocated. */
#define MUST_TRANSLATE_PATHNAME(pathname) \
do { \
if (pathname == NULL) \
return -EINVAL; \
while (*pathname == '/') \
pathname++; \
if (*pathname == '\0') \
pathname = "."; \
} 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_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) {
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) {
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;
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) {
(void)pathname;
if (datasync ? fdatasync(fi->fh) : fsync(fi->fh) == -1)
return -errno;
return 0;
}

View File

@@ -0,0 +1,34 @@
#define FUSE_USE_VERSION FUSE_MAKE_VERSION(3, 12)
#include <fuse.h>
#include <fuse_lowlevel.h> /* for fuse_cmdline_help */
#if (FUSE_VERSION < FUSE_MAKE_VERSION(3, 12))
#error This package requires libfuse >= v3.12
#endif
#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 */
/* sharefs_private is populated by sharefs_init and contains process-wide context */
struct sharefs_private {
int dirfd; /* source dirfd opened during sharefs_init */
uintptr_t setup; /* cgo handle of opaque setup state */
};
int sharefs_getattr(const char *pathname, struct stat *statbuf, struct fuse_file_info *fi);
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);

556
cmd/sharefs/fuse.go Normal file
View File

@@ -0,0 +1,556 @@
package main
/*
#cgo pkg-config: --static fuse3
#include "fuse-operations.h"
#include <stdlib.h>
#include <string.h>
extern void *sharefs_init(struct fuse_conn_info *conn, struct fuse_config *cfg);
extern void sharefs_destroy(void *private_data);
typedef void (*closure)();
static inline struct fuse_opt _FUSE_OPT_END() { return (struct fuse_opt)FUSE_OPT_END; };
*/
import "C"
import (
"context"
"encoding/gob"
"errors"
"fmt"
"io"
"log"
"os"
"os/exec"
"os/signal"
"path"
"runtime"
"runtime/cgo"
"strconv"
"syscall"
"unsafe"
"hakurei.app/container"
"hakurei.app/container/check"
"hakurei.app/container/std"
"hakurei.app/hst"
"hakurei.app/internal/helper/proc"
"hakurei.app/internal/info"
"hakurei.app/message"
)
type (
// closure represents a C function pointer.
closure = C.closure
// fuseArgs represents the fuse_args structure.
fuseArgs = C.struct_fuse_args
// setupState holds state used for setup. Its cgo handle is included in
// sharefs_private and considered opaque to non-setup callbacks.
setupState struct {
// Whether sharefs_init failed.
initFailed bool
// Whether to create source directory as root.
mkdir bool
// Open file descriptor to fuse.
Fuse int
// Pathname to open for dirfd.
Source *check.Absolute
// New uid and gid to set by sharefs_init when starting as root.
Setuid, Setgid int
}
)
func init() { gob.Register(new(setupState)) }
// destroySetup invalidates the setup [cgo.Handle] in a sharefs_private structure.
func destroySetup(private_data unsafe.Pointer) (ok bool) {
if private_data == nil {
return false
}
priv := (*C.struct_sharefs_private)(private_data)
if h := cgo.Handle(priv.setup); h != 0 {
priv.setup = 0
h.Delete()
ok = true
}
return
}
//export sharefs_init
func sharefs_init(_ *C.struct_fuse_conn_info, cfg *C.struct_fuse_config) unsafe.Pointer {
ctx := C.fuse_get_context()
priv := (*C.struct_sharefs_private)(ctx.private_data)
setup := cgo.Handle(priv.setup).Value().(*setupState)
if os.Geteuid() == 0 {
log.Println("filesystem daemon must not run as root")
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(setup.Source.String(), syscall.O_DIRECTORY|syscall.O_RDONLY|syscall.O_CLOEXEC, 0); err != nil {
log.Printf("cannot open %q: %v", setup.Source, err)
goto fail
} else if err = syscall.Fchdir(fd); err != nil {
_ = syscall.Close(fd)
log.Printf("cannot enter %q: %s", setup.Source, err)
goto fail
} else {
priv.dirfd = C.int(fd)
}
return ctx.private_data
fail:
setup.initFailed = true
C.fuse_exit(ctx.fuse)
return nil
}
//export sharefs_destroy
func sharefs_destroy(private_data unsafe.Pointer) {
if private_data != nil {
destroySetup(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(args *fuseArgs) {
executableName := sharefsName
if args.argc > 0 {
executableName = path.Base(C.GoString(*args.argv))
} else if name, err := os.Executable(); err == nil {
executableName = path.Base(name)
}
fmt.Printf("usage: %s [options] <mountpoint>\n\n", executableName)
fmt.Println("Filesystem options:")
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")
fmt.Println("\nFUSE options:")
C.fuse_cmdline_help()
C.fuse_lib_help(args)
}
// parseOpts parses fuse options via fuse_opt_parse.
func parseOpts(args *fuseArgs, setup *setupState, log *log.Logger) (ok bool) {
var unsafeOpts struct {
// Pathname to writable source directory.
source *C.char
// Whether to create source directory as root.
mkdir C.int
// 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
// Decimal string representation of open file descriptor to read setupState from.
// This is an internal detail for containerisation and must not be specified directly.
setup *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("mkdir"), offset: C.ulong(unsafe.Offsetof(unsafeOpts.mkdir)), value: 1},
{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},
{templ: C.CString("setup=%s"), offset: C.ulong(unsafe.Offsetof(unsafeOpts.setup)), value: 0},
C._FUSE_OPT_END(),
}[0], nil) == -1 {
return false
}
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.setup != nil {
defer C.free(unsafe.Pointer(unsafeOpts.setup))
if v, err := strconv.Atoi(C.GoString(unsafeOpts.setup)); err != nil || v < 3 {
log.Println("invalid value for option setup")
return false
} else {
r := os.NewFile(uintptr(v), "setup")
defer func() {
if err = r.Close(); err != nil {
log.Println(err)
}
}()
if err = gob.NewDecoder(r).Decode(setup); err != nil {
log.Println(err)
return false
}
}
if setup.Fuse < 3 {
log.Println("invalid file descriptor", setup.Fuse)
return false
}
return true
}
if unsafeOpts.source == nil {
showHelp(args)
return false
} else if a, err := check.NewAbs(C.GoString(unsafeOpts.source)); err != nil {
log.Println(err)
return false
} else {
setup.Source = a
}
setup.mkdir = unsafeOpts.mkdir != 0
if unsafeOpts.setuid == nil {
setup.Setuid = -1
} else if v, err := strconv.Atoi(C.GoString(unsafeOpts.setuid)); err != nil || v <= 0 {
log.Println("invalid value for option setuid")
return false
} else {
setup.Setuid = v
}
if unsafeOpts.setgid == nil {
setup.Setgid = -1
} else if v, err := strconv.Atoi(C.GoString(unsafeOpts.setgid)); err != nil || v <= 0 {
log.Println("invalid value for option setgid")
return false
} else {
setup.Setgid = v
}
return true
}
// copyArgs returns a heap allocated copy of an argument slice in fuse_args representation.
func copyArgs(s ...string) fuseArgs {
if len(s) == 0 {
return fuseArgs{argc: 0, argv: nil, allocated: 0}
}
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 fuseArgs{argc: C.int(len(s)), argv: &args[0], allocated: 1}
}
// freeArgs frees the contents of argument list.
func freeArgs(args *fuseArgs) { C.fuse_opt_free_args(args) }
// 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(s ...string) (exitCode int) {
msg := message.New(log.Default())
container.TryArgv0(msg)
runtime.LockOSThread()
// don't mask creation mode, kernel already did that
syscall.Umask(0)
var pinner runtime.Pinner
defer pinner.Unpin()
args := copyArgs(s...)
defer freeArgs(&args)
// this causes the kernel to enforce access control based on
// struct stat populated by sharefs_getattr
unsafeAddArgument(&args, "-odefault_permissions\x00")
var priv C.struct_sharefs_private
pinner.Pin(&priv)
var setup setupState
priv.setup = C.uintptr_t(cgo.NewHandle(&setup))
defer destroySetup(unsafe.Pointer(&priv))
var opts C.struct_fuse_cmdline_opts
if C.fuse_parse_cmdline(&args, &opts) != 0 {
return 1
}
if opts.mountpoint != nil {
defer C.free(unsafe.Pointer(opts.mountpoint))
}
if opts.show_version != 0 {
fmt.Println("hakurei version", info.Version())
fmt.Println("FUSE library version", C.GoString(C.fuse_pkgversion()))
C.fuse_lowlevel_version()
return 0
}
if opts.show_help != 0 {
showHelp(&args)
return 0
} else if opts.mountpoint == nil {
log.Println("no mountpoint specified")
return 2
} else {
// hack to keep fuse_parse_cmdline happy in the container
mountpoint := C.GoString(opts.mountpoint)
pathnameArg := -1
for i, arg := range s {
if arg == mountpoint {
pathnameArg = i
break
}
}
if pathnameArg < 0 {
log.Println("mountpoint must be absolute")
return 2
}
s[pathnameArg] = container.Nonexistent
}
if !parseOpts(&args, &setup, msg.GetLogger()) {
return 1
}
asRoot := os.Geteuid() == 0
if asRoot {
if setup.Setuid <= 0 || setup.Setgid <= 0 {
log.Println("setuid and setgid must not be 0")
return 1
}
if setup.Fuse >= 3 {
log.Println("filesystem daemon must not run as root")
return 1
}
if setup.mkdir {
if err := os.MkdirAll(setup.Source.String(), 0700); err != nil {
if !errors.Is(err, os.ErrExist) {
log.Println(err)
return 1
}
// skip setup for existing source directory
} else if err = os.Chown(setup.Source.String(), setup.Setuid, setup.Setgid); err != nil {
log.Println(err)
return 1
}
}
} else if setup.Fuse < 3 && (setup.Setuid > 0 || setup.Setgid > 0) {
log.Println("setuid and setgid has no effect when not starting as root")
return 1
} else if setup.mkdir {
log.Println("mkdir has no effect when not starting as root")
return 1
}
op := C.struct_fuse_operations{
init: closure(C.sharefs_init),
destroy: closure(C.sharefs_destroy),
// implemented in fuse-helper.c
getattr: closure(C.sharefs_getattr),
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),
}
fuse := C.fuse_new_fn(&args, &op, C.size_t(unsafe.Sizeof(op)), unsafe.Pointer(&priv))
if fuse == nil {
return 3
}
defer C.fuse_destroy(fuse)
se := C.fuse_get_session(fuse)
if setup.Fuse < 3 {
// unconfined, set up mount point and container
if C.fuse_mount(fuse, opts.mountpoint) != 0 {
return 4
}
// unmounted by initial process
defer func() {
if exitCode == 5 {
C.fuse_unmount(fuse)
}
}()
if asRoot {
if err := syscall.Setresgid(setup.Setgid, setup.Setgid, setup.Setgid); err != nil {
log.Printf("cannot set gid: %v", err)
return 5
}
if err := syscall.Setgroups(nil); err != nil {
log.Printf("cannot set supplementary groups: %v", err)
return 5
}
if err := syscall.Setresuid(setup.Setuid, setup.Setuid, setup.Setuid); err != nil {
log.Printf("cannot set uid: %v", err)
return 5
}
}
msg.SwapVerbose(opts.debug != 0)
ctx := context.Background()
if opts.foreground != 0 {
c, cancel := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
defer cancel()
ctx = c
}
z := container.New(ctx, msg)
z.AllowOrphan = opts.foreground == 0
z.Env = os.Environ()
// keep fuse_parse_cmdline happy in the container
z.Tmpfs(check.MustAbs(container.Nonexistent), 1<<10, 0755)
if a, err := check.NewAbs(container.MustExecutable(msg)); err != nil {
log.Println(err)
return 5
} else {
z.Path = a
}
z.Args = s
z.ForwardCancel = true
z.SeccompPresets |= std.PresetStrict
z.ParentPerm = 0700
z.Bind(setup.Source, setup.Source, std.BindWritable)
if !z.AllowOrphan {
z.WaitDelay = hst.WaitDelayMax
z.Stdin, z.Stdout, z.Stderr = os.Stdin, os.Stdout, os.Stderr
}
z.Bind(z.Path, z.Path, 0)
setup.Fuse = int(proc.ExtraFileSlice(&z.ExtraFiles, os.NewFile(uintptr(C.fuse_session_fd(se)), "fuse")))
var setupWriter io.WriteCloser
if fd, w, err := container.Setup(&z.ExtraFiles); err != nil {
log.Println(err)
return 5
} else {
z.Args = append(z.Args, "-osetup="+strconv.Itoa(fd))
setupWriter = w
}
if err := z.Start(); err != nil {
if m, ok := message.GetMessage(err); ok {
log.Println(m)
} else {
log.Println(err)
}
return 5
}
if err := z.Serve(); err != nil {
if m, ok := message.GetMessage(err); ok {
log.Println(m)
} else {
log.Println(err)
}
return 5
}
if err := gob.NewEncoder(setupWriter).Encode(&setup); err != nil {
log.Println(err)
return 5
} else if err = setupWriter.Close(); err != nil {
log.Println(err)
}
if !z.AllowOrphan {
if err := z.Wait(); err != nil {
var exitError *exec.ExitError
if !errors.As(err, &exitError) || exitError == nil {
log.Println(err)
return 5
}
switch code := exitError.ExitCode(); syscall.Signal(code & 0x7f) {
case syscall.SIGINT:
case syscall.SIGTERM:
default:
return code
}
}
}
return 0
} else { // confined
C.free(unsafe.Pointer(opts.mountpoint))
// must be heap allocated
opts.mountpoint = C.CString("/dev/fd/" + strconv.Itoa(setup.Fuse))
if err := os.Chdir("/"); err != nil {
log.Println(err)
}
}
if C.fuse_mount(fuse, opts.mountpoint) != 0 {
return 4
}
defer C.fuse_unmount(fuse)
if C.fuse_set_signal_handlers(se) != 0 {
return 6
}
defer C.fuse_remove_signal_handlers(se)
if opts.singlethread != 0 {
if C.fuse_loop(fuse) != 0 {
return 8
}
} else {
loopConfig := C.fuse_loop_cfg_create()
if loopConfig == nil {
return 7
}
defer C.fuse_loop_cfg_destroy(loopConfig)
C.fuse_loop_cfg_set_clone_fd(loopConfig, C.uint(opts.clone_fd))
C.fuse_loop_cfg_set_idle_threads(loopConfig, opts.max_idle_threads)
C.fuse_loop_cfg_set_max_threads(loopConfig, opts.max_threads)
if C.fuse_loop_mt(fuse, loopConfig) != 0 {
return 8
}
}
if setup.initFailed {
return 1
}
return 0
}

113
cmd/sharefs/fuse_test.go Normal file
View File

@@ -0,0 +1,113 @@
package main
import (
"bytes"
"log"
"reflect"
"testing"
"hakurei.app/container/check"
)
func TestParseOpts(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
args []string
want setupState
wantLog string
wantOk bool
}{
{"zero length", []string{}, setupState{}, "", false},
{"not absolute", []string{"sharefs",
"-o", "source=nonexistent",
"-o", "setuid=1023",
"-o", "setgid=1023",
}, setupState{}, "sharefs: path \"nonexistent\" is not absolute\n", false},
{"not specified", []string{"sharefs",
"-o", "setuid=1023",
"-o", "setgid=1023",
}, setupState{}, "", false},
{"invalid setuid", []string{"sharefs",
"-o", "source=/proc/nonexistent",
"-o", "setuid=ff",
"-o", "setgid=1023",
}, setupState{
Source: check.MustAbs("/proc/nonexistent"),
}, "sharefs: invalid value for option setuid\n", false},
{"invalid setgid", []string{"sharefs",
"-o", "source=/proc/nonexistent",
"-o", "setuid=1023",
"-o", "setgid=ff",
}, setupState{
Source: check.MustAbs("/proc/nonexistent"),
Setuid: 1023,
}, "sharefs: invalid value for option setgid\n", false},
{"simple", []string{"sharefs",
"-o", "source=/proc/nonexistent",
}, setupState{
Source: check.MustAbs("/proc/nonexistent"),
Setuid: -1,
Setgid: -1,
}, "", true},
{"root", []string{"sharefs",
"-o", "source=/proc/nonexistent",
"-o", "setuid=1023",
"-o", "setgid=1023",
}, setupState{
Source: check.MustAbs("/proc/nonexistent"),
Setuid: 1023,
Setgid: 1023,
}, "", true},
{"setuid", []string{"sharefs",
"-o", "source=/proc/nonexistent",
"-o", "setuid=1023",
}, setupState{
Source: check.MustAbs("/proc/nonexistent"),
Setuid: 1023,
Setgid: -1,
}, "", true},
{"setgid", []string{"sharefs",
"-o", "source=/proc/nonexistent",
"-o", "setgid=1023",
}, setupState{
Source: check.MustAbs("/proc/nonexistent"),
Setuid: -1,
Setgid: 1023,
}, "", true},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var (
got setupState
buf bytes.Buffer
)
args := copyArgs(tc.args...)
defer freeArgs(&args)
unsafeAddArgument(&args, "-odefault_permissions\x00")
if ok := parseOpts(&args, &got, log.New(&buf, "sharefs: ", 0)); ok != tc.wantOk {
t.Errorf("parseOpts: ok = %v, want %v", ok, tc.wantOk)
}
if !reflect.DeepEqual(&got, &tc.want) {
t.Errorf("parseOpts: setup = %#v, want %#v", got, tc.want)
}
if buf.String() != tc.wantLog {
t.Errorf("parseOpts: log =\n%s\nwant\n%s", buf.String(), tc.wantLog)
}
})
}
}

31
cmd/sharefs/main.go Normal file
View File

@@ -0,0 +1,31 @@
package main
import (
"log"
"os"
"slices"
)
// sharefsName is the prefix used by log.std in the sharefs process.
const sharefsName = "sharefs"
// handleMountArgs returns an alternative, libfuse-compatible args slice for
// args passed by mount -t fuse.sharefs [options] sharefs <mountpoint>.
//
// In this case, args always has a length of 5 with index 0 being what comes
// after "fuse." in the filesystem type, 1 is the uninterpreted string passed
// to mount (sharefsName is used as the magic string to enable this hack),
// 2 is passed through to libfuse as mountpoint, and 3 is always "-o".
func handleMountArgs(args []string) []string {
if len(args) == 5 && args[1] == sharefsName && args[3] == "-o" {
return []string{sharefsName, args[2], "-o", args[4]}
}
return slices.Clone(args)
}
func main() {
log.SetFlags(0)
log.SetPrefix(sharefsName + ": ")
os.Exit(_main(handleMountArgs(os.Args)...))
}

29
cmd/sharefs/main_test.go Normal file
View File

@@ -0,0 +1,29 @@
package main
import (
"slices"
"testing"
)
func TestHandleMountArgs(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
args []string
want []string
}{
{"nil", nil, nil},
{"passthrough", []string{"sharefs", "-V"}, []string{"sharefs", "-V"}},
{"replace", []string{"/sbin/sharefs", "sharefs", "/sdcard", "-o", "rw"}, []string{"sharefs", "/sdcard", "-o", "rw"}},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := handleMountArgs(tc.args); !slices.Equal(got, tc.want) {
t.Errorf("handleMountArgs: %q, want %q", got, tc.want)
}
})
}
}

View File

@@ -0,0 +1,41 @@
{ 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";
environment = {
# For benchmarking sharefs:
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";
};
};
}

View 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;
}

60
cmd/sharefs/test/test.py Normal file
View File

@@ -0,0 +1,60 @@
start_all()
machine.wait_for_unit("multi-user.target")
# To check sharefs version:
print(machine.succeed("sharefs -V"))
# Make sure sharefs started:
machine.wait_for_unit("sdcard.mount")
machine.succeed("mkdir /mnt")
def check_bad_opts_output(opts, want, source="/etc", privileged=False):
output = machine.fail(("" if privileged else "sudo -u alice -i ") + f"sharefs -f -o source={source},{opts} /mnt 2>&1")
if output != want:
raise Exception(f"unexpected output: {output}")
# Malformed setuid/setgid representation:
check_bad_opts_output("setuid=ff", "sharefs: invalid value for option setuid\n")
check_bad_opts_output("setgid=ff", "sharefs: invalid value for option setgid\n")
# Bounds check for setuid/setgid:
check_bad_opts_output("setuid=0", "sharefs: invalid value for option setuid\n")
check_bad_opts_output("setgid=0", "sharefs: invalid value for option setgid\n")
check_bad_opts_output("setuid=-1", "sharefs: invalid value for option setuid\n")
check_bad_opts_output("setgid=-1", "sharefs: invalid value for option setgid\n")
# Non-root setuid/setgid:
check_bad_opts_output("setuid=1023", "sharefs: setuid and setgid has no effect when not starting as root\n")
check_bad_opts_output("setgid=1023", "sharefs: setuid and setgid has no effect when not starting as root\n")
check_bad_opts_output("setuid=1023,setgid=1023", "sharefs: setuid and setgid has no effect when not starting as root\n")
check_bad_opts_output("mkdir", "sharefs: mkdir has no effect when not starting as root\n")
# Starting as root without setuid/setgid:
check_bad_opts_output("allow_other", "sharefs: setuid and setgid must not be 0\n", privileged=True)
check_bad_opts_output("setuid=1023", "sharefs: setuid and setgid must not be 0\n", privileged=True)
check_bad_opts_output("setgid=1023", "sharefs: setuid and setgid must not be 0\n", privileged=True)
# Make sure nothing actually got mounted:
machine.fail("umount /mnt")
machine.succeed("rmdir /mnt")
# Unprivileged mount/unmount:
machine.succeed("sudo -u alice -i mkdir /home/alice/{sdcard,persistent}")
machine.succeed("sudo -u alice -i sharefs -o source=/home/alice/persistent /home/alice/sdcard")
machine.succeed("sudo -u alice -i touch /home/alice/sdcard/check")
machine.succeed("sudo -u alice -i umount /home/alice/sdcard")
machine.succeed("sudo -u alice -i rm /home/alice/persistent/check")
machine.succeed("sudo -u alice -i rmdir /home/alice/{sdcard,persistent}")
# 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("sudo -u sharefs touch /var/lib/hakurei/sdcard/fs_mark/.check")
machine.succeed("sudo -u sharefs rm /var/lib/hakurei/sdcard/fs_mark/.check")
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 -i sharefs-workload-hakurei-tests")

View File

@@ -35,6 +35,8 @@ type (
// Container represents a container environment being prepared or run. // Container represents a container environment being prepared or run.
// None of [Container] methods are safe for concurrent use. // None of [Container] methods are safe for concurrent use.
Container struct { Container struct {
// Whether the container init should stay alive after its parent terminates.
AllowOrphan bool
// Cgroup fd, nil to disable. // Cgroup fd, nil to disable.
Cgroup *int Cgroup *int
// ExtraFiles passed through to initial process in the container, // ExtraFiles passed through to initial process in the container,
@@ -253,7 +255,6 @@ func (p *Container) Start() error {
p.cmd.Dir = fhs.Root p.cmd.Dir = fhs.Root
p.cmd.SysProcAttr = &SysProcAttr{ p.cmd.SysProcAttr = &SysProcAttr{
Setsid: !p.RetainSession, Setsid: !p.RetainSession,
Pdeathsig: SIGKILL,
Cloneflags: CLONE_NEWUSER | CLONE_NEWPID | CLONE_NEWNS | Cloneflags: CLONE_NEWUSER | CLONE_NEWPID | CLONE_NEWNS |
CLONE_NEWIPC | CLONE_NEWUTS | CLONE_NEWCGROUP, CLONE_NEWIPC | CLONE_NEWUTS | CLONE_NEWCGROUP,
@@ -268,6 +269,9 @@ func (p *Container) Start() error {
UseCgroupFD: p.Cgroup != nil, UseCgroupFD: p.Cgroup != nil,
} }
if !p.AllowOrphan {
p.cmd.SysProcAttr.Pdeathsig = SIGKILL
}
if p.cmd.SysProcAttr.UseCgroupFD { if p.cmd.SysProcAttr.UseCgroupFD {
p.cmd.SysProcAttr.CgroupFD = *p.Cgroup p.cmd.SysProcAttr.CgroupFD = *p.Cgroup
} }

2
dist/install.sh vendored
View File

@@ -2,7 +2,7 @@
cd "$(dirname -- "$0")" || exit 1 cd "$(dirname -- "$0")" || exit 1
install -vDm0755 "bin/hakurei" "${HAKUREI_INSTALL_PREFIX}/usr/bin/hakurei" install -vDm0755 "bin/hakurei" "${HAKUREI_INSTALL_PREFIX}/usr/bin/hakurei"
install -vDm0755 "bin/hpkg" "${HAKUREI_INSTALL_PREFIX}/usr/bin/hpkg" install -vDm0755 "bin/sharefs" "${HAKUREI_INSTALL_PREFIX}/usr/bin/sharefs"
install -vDm4511 "bin/hsu" "${HAKUREI_INSTALL_PREFIX}/usr/bin/hsu" install -vDm4511 "bin/hsu" "${HAKUREI_INSTALL_PREFIX}/usr/bin/hsu"
if [ ! -f "${HAKUREI_INSTALL_PREFIX}/etc/hsurc" ]; then if [ ! -f "${HAKUREI_INSTALL_PREFIX}/etc/hsurc" ]; then

View File

@@ -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 ]; } ''
@@ -136,6 +138,10 @@
; ;
}; };
hsu = pkgs.callPackage ./cmd/hsu/package.nix { inherit (self.packages.${system}) hakurei; }; hsu = pkgs.callPackage ./cmd/hsu/package.nix { inherit (self.packages.${system}) hakurei; };
sharefs = pkgs.linkFarm "sharefs" {
"bin/sharefs" = "${hakurei}/libexec/sharefs";
"bin/mount.fuse.sharefs" = "${hakurei}/libexec/sharefs";
};
dist = pkgs.runCommand "${hakurei.name}-dist" { buildInputs = hakurei.targetPkgs ++ [ pkgs.pkgsStatic.musl ]; } '' dist = pkgs.runCommand "${hakurei.name}-dist" { buildInputs = hakurei.targetPkgs ++ [ pkgs.pkgsStatic.musl ]; } ''
# go requires XDG_CACHE_HOME for the build cache # go requires XDG_CACHE_HOME for the build cache
@@ -160,7 +166,10 @@
pkgs = nixpkgsFor.${system}; pkgs = nixpkgsFor.${system};
in in
{ {
default = pkgs.mkShell { buildInputs = hakurei.targetPkgs; }; default = pkgs.mkShell {
buildInputs = hakurei.targetPkgs;
hardeningDisable = [ "fortify" ];
};
withPackage = pkgs.mkShell { buildInputs = [ hakurei ] ++ hakurei.targetPkgs; }; withPackage = pkgs.mkShell { buildInputs = [ hakurei ] ++ hakurei.targetPkgs; };
vm = vm =

View File

@@ -514,7 +514,7 @@ var ErrNotDone = errors.New("did not receive a Core::Done event targeting previo
const ( const (
// syncTimeout is the maximum duration [Core.Sync] is allowed to take before // syncTimeout is the maximum duration [Core.Sync] is allowed to take before
// receiving [CoreDone] or failing. // receiving [CoreDone] or failing.
syncTimeout = 5 * time.Second syncTimeout = 10 * time.Second
) )
// Sync queues a [CoreSync] message for the PipeWire server and initiates a Roundtrip. // Sync queues a [CoreSync] message for the PipeWire server and initiates a Roundtrip.

View File

@@ -19,7 +19,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net"
"os" "os"
"path" "path"
"runtime" "runtime"
@@ -27,10 +26,16 @@ import (
"strconv" "strconv"
"strings" "strings"
"syscall" "syscall"
"time"
) )
// Conn is a low level unix socket interface used by [Context]. // Conn is a low level unix socket interface used by [Context].
type Conn interface { type Conn interface {
// MightBlock informs the implementation that the next call to
// Recvmsg or Sendmsg might block. A zero or negative timeout
// cancels this behaviour.
MightBlock(timeout time.Duration)
// Recvmsg calls syscall.Recvmsg on the underlying socket. // Recvmsg calls syscall.Recvmsg on the underlying socket.
Recvmsg(p, oob []byte, flags int) (n, oobn, recvflags int, err error) Recvmsg(p, oob []byte, flags int) (n, oobn, recvflags int, err error)
@@ -138,45 +143,142 @@ func New(conn Conn, props SPADict) (*Context, error) {
return &ctx, nil return &ctx, nil
} }
// A SyscallConnCloser is a [syscall.Conn] that implements [io.Closer]. // unixConn is an implementation of the [Conn] interface for connections
type SyscallConnCloser interface { // to Unix domain sockets.
syscall.Conn type unixConn struct {
io.Closer fd int
// Whether creation of a new epoll instance was attempted.
epoll bool
// File descriptor referring to the new epoll instance.
// Valid if epoll is true and epollErr is nil.
epollFd int
// Error returned by syscall.EpollCreate1.
epollErr error
// Stores epoll events from the kernel.
epollBuf [32]syscall.EpollEvent
// If non-zero, next call is treated as a blocking call.
timeout time.Duration
} }
// A SyscallConn is a [Conn] adapter for [syscall.Conn]. // Dial connects to a Unix domain socket described by name.
type SyscallConn struct{ SyscallConnCloser } func Dial(name string) (Conn, error) {
if fd, err := syscall.Socket(syscall.AF_UNIX, syscall.SOCK_STREAM|syscall.SOCK_CLOEXEC|syscall.SOCK_NONBLOCK, 0); err != nil {
return nil, os.NewSyscallError("socket", err)
} else if err = syscall.Connect(fd, &syscall.SockaddrUnix{Name: name}); err != nil {
_ = syscall.Close(fd)
return nil, os.NewSyscallError("connect", err)
} else {
return &unixConn{fd: fd}, nil
}
}
// Recvmsg implements [Conn.Recvmsg] via [syscall.Conn.SyscallConn]. // MightBlock informs the implementation that the next call
func (conn SyscallConn) Recvmsg(p, oob []byte, flags int) (n, oobn, recvflags int, err error) { // might block for a non-zero timeout.
var rc syscall.RawConn func (conn *unixConn) MightBlock(timeout time.Duration) {
if rc, err = conn.SyscallConn(); err != nil { if timeout < 0 {
timeout = 0
}
conn.timeout = timeout
}
// wantsEpoll is called at the beginning of any method that might use epoll.
func (conn *unixConn) wantsEpoll() error {
if !conn.epoll {
conn.epoll = true
conn.epollFd, conn.epollErr = syscall.EpollCreate1(syscall.EPOLL_CLOEXEC)
if conn.epollErr == nil {
if conn.epollErr = syscall.EpollCtl(conn.epollFd, syscall.EPOLL_CTL_ADD, conn.fd, &syscall.EpollEvent{
Events: syscall.EPOLLERR | syscall.EPOLLHUP,
Fd: int32(conn.fd),
}); conn.epollErr != nil {
_ = syscall.Close(conn.epollFd)
}
}
}
return conn.epollErr
}
// wait waits for a specific I/O event on fd. Caller must arrange for wantsEpoll
// to be called somewhere before wait is called.
func (conn *unixConn) wait(event uint32) (err error) {
if conn.timeout == 0 {
return nil
}
deadline := time.Now().Add(conn.timeout)
conn.timeout = 0
if err = syscall.EpollCtl(conn.epollFd, syscall.EPOLL_CTL_MOD, conn.fd, &syscall.EpollEvent{
Events: event | syscall.EPOLLERR | syscall.EPOLLHUP,
Fd: int32(conn.fd),
}); err != nil {
return return
} }
if controlErr := rc.Control(func(fd uintptr) { for timeout := deadline.Sub(time.Now()); timeout > 0; timeout = deadline.Sub(time.Now()) {
n, oobn, recvflags, _, err = syscall.Recvmsg(int(fd), p, oob, flags) var n int
}); controlErr != nil && err == nil { if n, err = syscall.EpollWait(conn.epollFd, conn.epollBuf[:], int(timeout/time.Millisecond)); err != nil {
err = controlErr return
}
switch n {
case 1: // only the socket fd is ever added
if conn.epollBuf[0].Fd != int32(conn.fd) { // unreachable
return syscall.ENOTRECOVERABLE
}
if conn.epollBuf[0].Events&event == event ||
conn.epollBuf[0].Events&syscall.EPOLLERR|syscall.EPOLLHUP != 0 {
return nil
}
err = syscall.ETIME
continue
case 0: // timeout
return syscall.ETIMEDOUT
default: // unreachable
return syscall.ENOTRECOVERABLE
}
} }
return return
} }
// Sendmsg implements [Conn.Sendmsg] via [syscall.Conn.SyscallConn]. // Recvmsg calls syscall.Recvmsg on the underlying socket.
func (conn SyscallConn) Sendmsg(p, oob []byte, flags int) (n int, err error) { func (conn *unixConn) Recvmsg(p, oob []byte, flags int) (n, oobn, recvflags int, err error) {
var rc syscall.RawConn if err = conn.wantsEpoll(); err != nil {
if rc, err = conn.SyscallConn(); err != nil { return
} else if err = conn.wait(syscall.EPOLLIN); err != nil {
return return
} }
if controlErr := rc.Control(func(fd uintptr) { n, oobn, recvflags, _, err = syscall.Recvmsg(conn.fd, p, oob, flags)
n, err = syscall.SendmsgN(int(fd), p, oob, nil, flags)
}); controlErr != nil && err == nil {
err = controlErr
}
return return
} }
// Sendmsg calls syscall.Sendmsg on the underlying socket.
func (conn *unixConn) Sendmsg(p, oob []byte, flags int) (n int, err error) {
if err = conn.wantsEpoll(); err != nil {
return
} else if err = conn.wait(syscall.EPOLLOUT); err != nil {
return
}
n, err = syscall.SendmsgN(conn.fd, p, oob, nil, flags)
return
}
// Close closes the underlying socket and the epoll fd if populated.
func (conn *unixConn) Close() (err error) {
if conn.epoll && conn.epollErr == nil {
conn.epollErr = syscall.Close(conn.epollFd)
}
if err = syscall.Close(conn.fd); err != nil {
return
}
return conn.epollErr
}
// MustNew calls [New](conn, props) and panics on error. // MustNew calls [New](conn, props) and panics on error.
// It is intended for use in tests with hard-coded strings. // It is intended for use in tests with hard-coded strings.
func MustNew(conn Conn, props SPADict) *Context { func MustNew(conn Conn, props SPADict) *Context {
@@ -310,7 +412,7 @@ func (ctx *Context) recvmsg(remaining []byte) (payload []byte, err error) {
} }
if err != syscall.EAGAIN && err != syscall.EWOULDBLOCK { if err != syscall.EAGAIN && err != syscall.EWOULDBLOCK {
ctx.closeReceivedFiles() ctx.closeReceivedFiles()
return nil, os.NewSyscallError("recvmsg", err) return nil, &ProxyFatalError{Err: os.NewSyscallError("recvmsg", err), ProxyErrs: ctx.cloneAsProxyErrors()}
} }
} }
@@ -347,7 +449,7 @@ func (ctx *Context) sendmsg(p []byte, fds ...int) error {
} }
if err != nil && err != syscall.EAGAIN && err != syscall.EWOULDBLOCK { if err != nil && err != syscall.EAGAIN && err != syscall.EWOULDBLOCK {
return os.NewSyscallError("sendmsg", err) return &ProxyFatalError{Err: os.NewSyscallError("sendmsg", err), ProxyErrs: ctx.cloneAsProxyErrors()}
} }
return err return err
} }
@@ -598,8 +700,15 @@ func (ctx *Context) Roundtrip() (err error) {
return return
} }
const (
// roundtripTimeout is the maximum duration socket operations during
// Context.roundtrip is allowed to block for.
roundtripTimeout = 5 * time.Second
)
// roundtrip implements the Roundtrip method without checking proxyErrors. // roundtrip implements the Roundtrip method without checking proxyErrors.
func (ctx *Context) roundtrip() (err error) { func (ctx *Context) roundtrip() (err error) {
ctx.conn.MightBlock(roundtripTimeout)
if err = ctx.sendmsg(ctx.buf, ctx.pendingFiles...); err != nil { if err = ctx.sendmsg(ctx.buf, ctx.pendingFiles...); err != nil {
return return
} }
@@ -633,6 +742,7 @@ func (ctx *Context) roundtrip() (err error) {
}() }()
var remaining []byte var remaining []byte
ctx.conn.MightBlock(roundtripTimeout)
for { for {
remaining, err = ctx.consume(remaining) remaining, err = ctx.consume(remaining)
if err == nil { if err == nil {
@@ -857,14 +967,14 @@ const Remote = "PIPEWIRE_REMOTE"
const DEFAULT_SYSTEM_RUNTIME_DIR = "/run/pipewire" const DEFAULT_SYSTEM_RUNTIME_DIR = "/run/pipewire"
// connectName connects to a PipeWire remote by name and returns the [net.UnixConn]. // connectName connects to a PipeWire remote by name and returns the resulting [Conn].
func connectName(name string, manager bool) (conn *net.UnixConn, err error) { func connectName(name string, manager bool) (conn Conn, err error) {
if manager && !strings.HasSuffix(name, "-manager") { if manager && !strings.HasSuffix(name, "-manager") {
return connectName(name+"-manager", false) return connectName(name+"-manager", false)
} }
if path.IsAbs(name) || (len(name) > 0 && name[0] == '@') { if path.IsAbs(name) || (len(name) > 0 && name[0] == '@') {
return net.DialUnix("unix", nil, &net.UnixAddr{Name: name, Net: "unix"}) return Dial(name)
} else { } else {
runtimeDir, ok := os.LookupEnv("PIPEWIRE_RUNTIME_DIR") runtimeDir, ok := os.LookupEnv("PIPEWIRE_RUNTIME_DIR")
if !ok || !path.IsAbs(runtimeDir) { if !ok || !path.IsAbs(runtimeDir) {
@@ -879,7 +989,7 @@ func connectName(name string, manager bool) (conn *net.UnixConn, err error) {
if !ok || !path.IsAbs(runtimeDir) { if !ok || !path.IsAbs(runtimeDir) {
runtimeDir = DEFAULT_SYSTEM_RUNTIME_DIR runtimeDir = DEFAULT_SYSTEM_RUNTIME_DIR
} }
return net.DialUnix("unix", nil, &net.UnixAddr{Name: path.Join(runtimeDir, name), Net: "unix"}) return Dial(path.Join(runtimeDir, name))
} }
} }
@@ -897,12 +1007,11 @@ func ConnectName(name string, manager bool, props SPADict) (ctx *Context, err er
} }
} }
var conn *net.UnixConn var conn Conn
if conn, err = connectName(name, manager); err != nil { if conn, err = connectName(name, manager); err != nil {
return return
} }
if ctx, err = New(conn, props); err != nil {
if ctx, err = New(SyscallConn{conn}, props); err != nil {
ctx = nil ctx = nil
_ = conn.Close() _ = conn.Close()
} }

View File

@@ -6,6 +6,7 @@ import (
"strconv" "strconv"
. "syscall" . "syscall"
"testing" "testing"
"time"
"hakurei.app/container/stub" "hakurei.app/container/stub"
"hakurei.app/internal/pipewire" "hakurei.app/internal/pipewire"
@@ -715,6 +716,18 @@ type stubUnixConn struct {
current int current int
} }
func (conn *stubUnixConn) MightBlock(timeout time.Duration) {
if timeout != 5*time.Second {
panic("unexpected timeout " + timeout.String())
}
if conn.current == 0 ||
(conn.samples[conn.current-1].nr == SYS_RECVMSG && conn.samples[conn.current-1].errno == EAGAIN && conn.samples[conn.current].nr == SYS_SENDMSG) ||
(conn.samples[conn.current-1].nr == SYS_SENDMSG && conn.samples[conn.current].nr == SYS_RECVMSG) {
return
}
panic("unexpected blocking hint before sample " + strconv.Itoa(conn.current))
}
// nextSample returns the current sample and increments the counter. // nextSample returns the current sample and increments the counter.
func (conn *stubUnixConn) nextSample(nr uintptr) (sample *stubUnixConnSample, wantOOB []byte, err error) { func (conn *stubUnixConn) nextSample(nr uintptr) (sample *stubUnixConnSample, wantOOB []byte, err error) {
sample = &conn.samples[conn.current] sample = &conn.samples[conn.current]

307
internal/pkg/pkg.go Normal file
View File

@@ -0,0 +1,307 @@
// Package pkg provides utilities for packaging software.
package pkg
import (
"crypto/sha512"
"encoding/base64"
"encoding/gob"
"errors"
"io"
"io/fs"
"os"
"sync"
"hakurei.app/container/check"
)
type (
// A Checksum is a SHA-384 checksum computed for a cured [Artifact].
Checksum = [sha512.Size384]byte
// An ID is a unique identifier returned by [Artifact.ID]. This value must
// be deterministically determined ahead of time.
ID Checksum
)
// MustDecode decodes a string representation of [Checksum] and panics if there
// is a decoding error or the resulting data is too short.
func MustDecode(s string) (checksum Checksum) {
if n, err := base64.URLEncoding.Decode(
checksum[:],
[]byte(s),
); err != nil {
panic(err)
} else if n != len(Checksum{}) {
panic(io.ErrUnexpectedEOF)
}
return
}
// An Artifact is a read-only reference to a piece of data that may be created
// deterministically but might not currently be available in memory or on the
// filesystem.
type Artifact interface {
// ID returns a globally unique identifier referring to the current
// [Artifact]. This value must be known ahead of time and guaranteed to be
// unique without having obtained the full contents of the [Artifact].
ID() ID
// Hash returns the [Checksum] created from the full contents of a cured
// [Artifact]. This can be stored for future lookup in a [Cache].
//
// A call to Hash implicitly cures [Artifact].
Hash() (Checksum, error)
// Pathname returns an absolute pathname to a file or directory populated
// with the full contents of [Artifact]. This is the most expensive
// operation possible on any [Artifact] and should be avoided if possible.
//
// A call to Pathname implicitly cures [Artifact].
//
// Callers must only open files read-only. If [Artifact] is a directory,
// files must not be created or removed under this directory.
Pathname() (*check.Absolute, error)
}
// A File refers to an [Artifact] backed by a single file.
type File interface {
// Data returns the full contents of [Artifact].
//
// Callers must not modify the returned byte slice.
Data() ([]byte, error)
Artifact
}
// FlatEntry is the representation of a directory entry via [Flatten].
type FlatEntry struct {
Name string // base name of the file
Mode fs.FileMode // file mode bits
Data []byte // file content or symlink destination
}
// Flatten writes a deterministic representation of the contents of fsys to w.
// The resulting data can be hashed to produce a deterministic checksum for the
// directory.
func Flatten(fsys fs.FS, root string, w io.Writer) error {
e := gob.NewEncoder(w)
return fs.WalkDir(fsys, root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
var fi fs.FileInfo
fi, err = d.Info()
if err != nil {
return err
}
ent := FlatEntry{
Name: fi.Name(),
Mode: fi.Mode(),
}
if ent.Mode.IsRegular() {
if ent.Data, err = fs.ReadFile(fsys, path); err != nil {
return err
}
} else if ent.Mode&fs.ModeSymlink != 0 {
var newpath string
if newpath, err = fs.ReadLink(fsys, path); err != nil {
return err
}
ent.Data = []byte(newpath)
}
return e.Encode(&ent)
})
}
// HashFS returns a checksum produced by hashing the result of [Flatten].
func HashFS(fsys fs.FS, root string) (Checksum, error) {
h := sha512.New384()
if err := Flatten(fsys, root, h); err != nil {
return Checksum{}, err
}
return (Checksum)(h.Sum(nil)), nil
}
// HashDir returns a checksum produced by hashing the result of [Flatten].
func HashDir(pathname *check.Absolute) (Checksum, error) {
return HashFS(os.DirFS(pathname.String()), ".")
}
const (
// dirIdentifier is the directory name appended to Cache.base for storing
// artifacts named after their [ID].
dirIdentifier = "identifier"
// dirChecksum is the directory name appended to Cache.base for storing
// artifacts named after their [Checksum].
dirChecksum = "checksum"
)
// Cache is a support layer that implementations of [Artifact] can use to store
// cured [Artifact] data in a content addressed fashion.
type Cache struct {
// Directory where all [Cache] related files are placed.
base *check.Absolute
// Synchronises access to public methods.
mu sync.RWMutex
}
// LoadFile loads the contents of a [File] by its identifier.
func (c *Cache) LoadFile(id ID) (
pathname *check.Absolute,
data []byte,
err error,
) {
pathname = c.base.Append(
dirIdentifier,
base64.URLEncoding.EncodeToString(id[:]),
)
c.mu.RLock()
data, err = os.ReadFile(pathname.String())
c.mu.RUnlock()
return
}
// A ChecksumMismatchError describes an [Artifact] with unexpected content.
type ChecksumMismatchError struct {
// Actual and expected checksums.
Got, Want Checksum
}
func (e *ChecksumMismatchError) Error() string {
return "got " + base64.URLEncoding.EncodeToString(e.Got[:]) +
" instead of " + base64.URLEncoding.EncodeToString(e.Want[:])
}
// pathname returns the content-addressed pathname for a [Checksum].
func (c *Cache) pathname(checksum *Checksum) *check.Absolute {
return c.base.Append(
dirChecksum,
base64.URLEncoding.EncodeToString(checksum[:]),
)
}
// pathnameIdent returns the identifier-based pathname for an [ID].
func (c *Cache) pathnameIdent(id *ID) *check.Absolute {
return c.base.Append(
dirIdentifier,
base64.URLEncoding.EncodeToString(id[:]),
)
}
// storeFile stores the contents of a [File]. An optional checksum can be
// passed via the result buffer which is used to validate the submitted data.
//
// If locking is disabled, the caller is responsible for acquiring a write lock
// and releasing it after this method returns. This makes LoadOrStoreFile
// possible without holding the lock while computing hash for store only.
func (c *Cache) storeFile(
identifierPathname *check.Absolute,
data []byte,
buf *Checksum,
validate, lock bool,
) error {
h := sha512.New384()
h.Write(data)
if validate {
if got := (Checksum)(h.Sum(nil)); got != *buf {
return &ChecksumMismatchError{got, *buf}
}
} else {
h.Sum(buf[:0])
}
checksumPathname := c.pathname(buf)
if lock {
c.mu.Lock()
defer c.mu.Unlock()
}
if f, err := os.OpenFile(
checksumPathname.String(),
os.O_WRONLY|os.O_CREATE|os.O_EXCL,
0400,
); err != nil {
// two artifacts may be backed by the same file
if !errors.Is(err, os.ErrExist) {
return err
}
} else if _, err = f.Write(data); err != nil {
// do not attempt cleanup: this is content-addressed and a partial
// write is caught during integrity check
return err
}
return os.Link(
checksumPathname.String(),
identifierPathname.String(),
)
}
// StoreFile stores the contents of a [File]. An optional checksum can be
// passed via the result buffer which is used to validate the submitted data.
func (c *Cache) StoreFile(
id ID,
data []byte,
buf *Checksum,
validate bool,
) (pathname *check.Absolute, err error) {
pathname = c.pathnameIdent(&id)
err = c.storeFile(pathname, data, buf, validate, true)
return
}
// LoadOrStoreFile attempts to load the contents of a [File] by its identifier,
// and if that file is not present, calls makeData and stores its result
// instead. Hash validation behaviour is identical to StoreFile.
func (c *Cache) LoadOrStoreFile(
id ID,
makeData func() ([]byte, error),
buf *Checksum,
validate bool,
) (
pathname *check.Absolute,
data []byte,
store bool,
err error,
) {
pathname = c.pathnameIdent(&id)
c.mu.Lock()
defer c.mu.Unlock()
data, err = os.ReadFile(pathname.String())
if err == nil || !errors.Is(err, os.ErrNotExist) {
return
}
store = true
data, err = makeData()
if err != nil {
return
}
err = c.storeFile(pathname, data, buf, validate, false)
return
}
// New returns the address to a new instance of [Cache].
func New(base *check.Absolute) (*Cache, error) {
for _, name := range []string{
dirIdentifier,
dirChecksum,
} {
if err := os.MkdirAll(base.Append(name).String(), 0700); err != nil &&
!errors.Is(err, os.ErrExist) {
return nil, err
}
}
return &Cache{
base: base,
}, nil
}

366
internal/pkg/pkg_test.go Normal file
View File

@@ -0,0 +1,366 @@
package pkg_test
import (
"bytes"
"crypto/sha512"
"encoding/base64"
"io/fs"
"os"
"path/filepath"
"reflect"
"syscall"
"testing"
"testing/fstest"
"hakurei.app/container"
"hakurei.app/container/check"
"hakurei.app/container/stub"
"hakurei.app/internal/pkg"
)
func TestCache(t *testing.T) {
t.Parallel()
const testdata = "" +
"\x00\x00\x00\x00" +
"\xad\x0b\x00" +
"\x04" +
"\xfe\xfe\x00\x00" +
"\xfe\xca\x00\x00"
testdataChecksum := func() pkg.Checksum {
h := sha512.New384()
h.Write([]byte(testdata))
return (pkg.Checksum)(h.Sum(nil))
}()
testdataChecksumString := base64.URLEncoding.EncodeToString(testdataChecksum[:])
testCases := []struct {
name string
early func(t *testing.T, base *check.Absolute)
f func(t *testing.T, base *check.Absolute, c *pkg.Cache)
check func(t *testing.T, base *check.Absolute)
}{
{"file", nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) {
wantErrNonexistent := &os.PathError{
Op: "open",
Path: base.Append(
"identifier",
testdataChecksumString,
).String(),
Err: syscall.ENOENT,
}
if _, _, err := c.LoadFile(testdataChecksum); !reflect.DeepEqual(err, wantErrNonexistent) {
t.Fatalf("LoadFile: error = %#v, want %#v", err, wantErrNonexistent)
}
identifier := (pkg.ID)(bytes.Repeat([]byte{
0x75, 0xe6, 0x9d, 0x6d, 0xe7, 0x9f,
}, 8))
wantPathname := base.Append(
"identifier",
"deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
)
identifier0 := (pkg.ID)(bytes.Repeat([]byte{
0x71, 0xa7, 0xde, 0x6d, 0xa6, 0xde,
}, 8))
wantPathname0 := base.Append(
"identifier",
"cafebabecafebabecafebabecafebabecafebabecafebabecafebabecafebabe",
)
// initial store
if pathname, err := c.StoreFile(
identifier,
[]byte(testdata),
&testdataChecksum,
true,
); err != nil {
t.Fatalf("StoreFile: error = %v", err)
} else if !pathname.Is(wantPathname) {
t.Fatalf("StoreFile: pathname = %q, want %q", pathname, wantPathname)
}
// load or store, identical content
if pathname, data, store, err := c.LoadOrStoreFile(identifier0, func() ([]byte, error) {
return []byte(testdata), nil
}, &testdataChecksum, true); err != nil {
t.Fatalf("LoadOrStoreFile: error = %v", err)
} else if !pathname.Is(wantPathname0) {
t.Fatalf("LoadOrStoreFile: pathname = %q, want %q", pathname, wantPathname0)
} else if string(data) != testdata {
t.Fatalf("LoadOrStoreFile: data = %x, want %x", data, testdata)
} else if !store {
t.Fatal("LoadOrStoreFile did not store nonpresent entry")
}
// load or store, existing entry
if pathname, data, store, err := c.LoadOrStoreFile(identifier, func() ([]byte, error) {
return []byte(testdata), nil
}, &testdataChecksum, true); err != nil {
t.Fatalf("LoadOrStoreFile: error = %v", err)
} else if !pathname.Is(wantPathname) {
t.Fatalf("LoadOrStoreFile: pathname = %q, want %q", pathname, wantPathname)
} else if string(data) != testdata {
t.Fatalf("LoadOrStoreFile: data = %x, want %x", data, testdata)
} else if store {
t.Fatal("LoadOrStoreFile stored over present entry")
}
// load, existing entry
if pathname, data, err := c.LoadFile(identifier0); err != nil {
t.Fatalf("LoadFile: error = %v", err)
} else if !pathname.Is(wantPathname0) {
t.Fatalf("LoadFile: pathname = %q, want %q", pathname, wantPathname0)
} else if string(data) != testdata {
t.Fatalf("LoadFile: data = %x, want %x", data, testdata)
}
// checksum mismatch
wantErrChecksum := &pkg.ChecksumMismatchError{
Got: testdataChecksum,
}
if _, err := c.StoreFile(
testdataChecksum,
[]byte(testdata),
new(pkg.Checksum),
true,
); !reflect.DeepEqual(err, wantErrChecksum) {
t.Fatalf("StoreFile: error = %#v, want %#v", err, wantErrChecksum)
}
// verify failed store
if _, _, err := c.LoadFile(testdataChecksum); !reflect.DeepEqual(err, wantErrNonexistent) {
t.Fatalf("LoadFile: error = %#v, want %#v", err, wantErrNonexistent)
}
// store, same identifier
wantPathnameF := base.Append(
"identifier",
testdataChecksumString,
)
if pathname, err := c.StoreFile(
testdataChecksum,
[]byte(testdata),
&testdataChecksum,
true,
); err != nil {
t.Fatalf("StoreFile: error = %v", err)
} else if !pathname.Is(wantPathnameF) {
t.Fatalf("StoreFile: pathname = %q, want %q", pathname, wantPathnameF)
}
// load, same identifier
if pathname, data, err := c.LoadFile(testdataChecksum); err != nil {
t.Fatalf("LoadFile: error = %v", err)
} else if !pathname.Is(wantPathnameF) {
t.Fatalf("LoadFile: pathname = %q, want %q", pathname, wantPathnameF)
} else if string(data) != testdata {
t.Fatalf("LoadFile: data = %x, want %x", data, testdata)
}
// store without validation
wantChecksum := pkg.Checksum{
0xbe, 0xc0, 0x21, 0xb4, 0xf3, 0x68,
0xe3, 0x06, 0x91, 0x34, 0xe0, 0x12,
0xc2, 0xb4, 0x30, 0x70, 0x83, 0xd3,
0xa9, 0xbd, 0xd2, 0x06, 0xe2, 0x4e,
0x5f, 0x0d, 0x86, 0xe1, 0x3d, 0x66,
0x36, 0x65, 0x59, 0x33, 0xec, 0x2b,
0x41, 0x34, 0x65, 0x96, 0x68, 0x17,
0xa9, 0xc2, 0x08, 0xa1, 0x17, 0x17,
}
var gotChecksum pkg.Checksum
wantPathnameG := base.Append(
"identifier",
base64.URLEncoding.EncodeToString(wantChecksum[:]),
)
if pathname, err := c.StoreFile(
wantChecksum,
[]byte{0},
&gotChecksum,
false,
); err != nil {
t.Fatalf("StoreFile: error = %#v", err)
} else if !pathname.Is(wantPathnameG) {
t.Fatalf("StoreFile: pathname = %q, want %q", pathname, wantPathnameG)
} else if gotChecksum != wantChecksum {
t.Fatalf("StoreFile: buf = %x, want %x", gotChecksum, wantChecksum)
}
// makeData passthrough
var zeroIdent pkg.ID
wantErrPassthrough := stub.UniqueError(0xcafe)
if _, _, _, err := c.LoadOrStoreFile(zeroIdent, func() ([]byte, error) {
return nil, wantErrPassthrough
}, new(pkg.Checksum), true); !reflect.DeepEqual(err, wantErrPassthrough) {
t.Fatalf("LoadOrStoreFile: error = %#v, want %#v", err, wantErrPassthrough)
}
// verify failed store
wantErrNonexistentZero := &os.PathError{
Op: "open",
Path: base.Append(
"identifier",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
).String(),
Err: syscall.ENOENT,
}
if _, _, err := c.LoadFile(zeroIdent); !reflect.DeepEqual(err, wantErrNonexistentZero) {
t.Fatalf("LoadFile: error = %#v, want %#v", err, wantErrNonexistentZero)
}
}, func(t *testing.T, base *check.Absolute) {
wantChecksum := pkg.MustDecode(
"lvK4lY9bQUFscHpxqHmiPvptjUwOgn3BFhzCXZMeupkY1n22WUPSuh7pswEvVZrx",
)
if checksum, err := pkg.HashDir(base); err != nil {
t.Fatalf("HashDir: error = %v", err)
} else if checksum != wantChecksum {
t.Fatalf("HashDir: %v", &pkg.ChecksumMismatchError{
Got: checksum,
Want: wantChecksum,
})
}
}},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
base := check.MustAbs(t.TempDir())
if err := os.Chmod(base.String(), 0700); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := filepath.WalkDir(base.String(), func(path string, d fs.DirEntry, err error) error {
if err != nil {
t.Error(err)
return nil
}
if !d.IsDir() {
return nil
}
return os.Chmod(path, 0700)
}); err != nil {
t.Fatal(err)
}
})
if c, err := pkg.New(base); err != nil {
t.Fatalf("New: error = %v", err)
} else {
if tc.early != nil {
tc.early(t, base)
}
tc.f(t, base, c)
tc.check(t, base)
}
})
}
}
func TestFlatten(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
fsys fs.FS
want pkg.Checksum
}{
{"sample cache file", fstest.MapFS{
".": {Mode: 020000000700},
"checksum": {Mode: 020000000700},
"checksum/vsAhtPNo4waRNOASwrQwcIPTqb3SBuJOXw2G4T1mNmVZM-wrQTRllmgXqcIIoRcX": {Mode: 0400, Data: []byte{0x0}},
"checksum/0bSFPu5Tnd-2Jj0Mv6co23PW2t3BmHc7eLFj9TgY3eIBg8zislo7xZYNBqovVLcq": {Mode: 0400, Data: []byte{0x0, 0x0, 0x0, 0x0, 0xad, 0xb, 0x0, 0x4, 0xfe, 0xfe, 0x0, 0x0, 0xfe, 0xca, 0x0, 0x0}},
"identifier": {Mode: 020000000700},
"identifier/vsAhtPNo4waRNOASwrQwcIPTqb3SBuJOXw2G4T1mNmVZM-wrQTRllmgXqcIIoRcX": {Mode: 0400, Data: []byte{0x0}},
"identifier/0bSFPu5Tnd-2Jj0Mv6co23PW2t3BmHc7eLFj9TgY3eIBg8zislo7xZYNBqovVLcq": {Mode: 0400, Data: []byte{0x0, 0x0, 0x0, 0x0, 0xad, 0xb, 0x0, 0x4, 0xfe, 0xfe, 0x0, 0x0, 0xfe, 0xca, 0x0, 0x0}},
"identifier/cafebabecafebabecafebabecafebabecafebabecafebabecafebabecafebabe": {Mode: 0400, Data: []byte{0x0, 0x0, 0x0, 0x0, 0xad, 0xb, 0x0, 0x4, 0xfe, 0xfe, 0x0, 0x0, 0xfe, 0xca, 0x0, 0x0}},
"identifier/deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef": {Mode: 0400, Data: []byte{0x0, 0x0, 0x0, 0x0, 0xad, 0xb, 0x0, 0x4, 0xfe, 0xfe, 0x0, 0x0, 0xfe, 0xca, 0x0, 0x0}},
}, pkg.MustDecode("lvK4lY9bQUFscHpxqHmiPvptjUwOgn3BFhzCXZMeupkY1n22WUPSuh7pswEvVZrx")},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got, err := pkg.HashFS(tc.fsys, "."); err != nil {
t.Fatalf("HashFS: error = %v", err)
} else if got != tc.want {
t.Fatalf("HashFS: %v", &pkg.ChecksumMismatchError{
Got: got,
Want: tc.want,
})
}
})
}
}
func TestErrors(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
err error
want string
}{
{"ChecksumMismatchError", &pkg.ChecksumMismatchError{
Want: (pkg.Checksum)(bytes.Repeat([]byte{
0x75, 0xe6, 0x9d, 0x6d, 0xe7, 0x9f,
}, 8)),
}, "got AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +
" instead of deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := tc.err.Error(); got != tc.want {
t.Errorf("Error: %q, want %q", got, tc.want)
}
})
}
}
func TestNew(t *testing.T) {
t.Parallel()
t.Run("nonexistent", func(t *testing.T) {
t.Parallel()
wantErr := &os.PathError{
Op: "mkdir",
Path: container.Nonexistent,
Err: syscall.ENOENT,
}
if _, err := pkg.New(check.MustAbs(container.Nonexistent)); !reflect.DeepEqual(err, wantErr) {
t.Errorf("New: error = %#v, want %#v", err, wantErr)
}
})
t.Run("permission", func(t *testing.T) {
t.Parallel()
tempDir := check.MustAbs(t.TempDir())
if err := os.Chmod(tempDir.String(), 0); err != nil {
t.Fatal(err)
} else {
t.Cleanup(func() {
if err = os.Chmod(tempDir.String(), 0700); err != nil {
t.Fatal(err)
}
})
}
wantErr := &os.PathError{
Op: "mkdir",
Path: tempDir.Append("cache").String(),
Err: syscall.EACCES,
}
if _, err := pkg.New(tempDir.Append("cache")); !reflect.DeepEqual(err, wantErr) {
t.Errorf("New: error = %#v, want %#v", err, wantErr)
}
})
}

View File

@@ -6,6 +6,7 @@ import (
"path" "path"
"syscall" "syscall"
"testing" "testing"
"time"
"hakurei.app/container/stub" "hakurei.app/container/stub"
"hakurei.app/internal/acl" "hakurei.app/internal/acl"
@@ -497,6 +498,12 @@ type stubPipeWireConn struct {
curSendmsg int curSendmsg int
} }
func (conn *stubPipeWireConn) MightBlock(timeout time.Duration) {
if timeout != 5*time.Second {
panic("unexpected timeout " + timeout.String())
}
}
// Recvmsg marshals and copies a stubMessage prepared ahead of time. // Recvmsg marshals and copies a stubMessage prepared ahead of time.
func (conn *stubPipeWireConn) Recvmsg(p, _ []byte, _ int) (n, _, recvflags int, err error) { func (conn *stubPipeWireConn) Recvmsg(p, _ []byte, _ int) (n, _, recvflags int, err error) {
defer func() { conn.curRecvmsg++ }() defer func() { conn.curRecvmsg++ }()

View File

@@ -24,11 +24,38 @@ let
getsubuid = userid: appid: userid * 100000 + 10000 + appid; getsubuid = userid: appid: userid * 100000 + 10000 + appid;
getsubname = userid: appid: "u${toString userid}_a${toString appid}"; getsubname = userid: appid: "u${toString userid}_a${toString appid}";
getsubhome = userid: appid: "${cfg.stateDir}/u${toString userid}/a${toString appid}"; getsubhome = userid: appid: "${cfg.stateDir}/u${toString userid}/a${toString appid}";
mountpoints = {
${cfg.sharefs.name} = mkIf (cfg.sharefs.source != null) {
depends = [ cfg.sharefs.source ];
device = "sharefs";
fsType = "fuse.sharefs";
noCheck = true;
options = [
"rw"
"noexec"
"nosuid"
"nodev"
"noatime"
"allow_other"
"mkdir"
"source=${cfg.sharefs.source}"
"setuid=${toString config.users.users.${cfg.sharefs.user}.uid}"
"setgid=${toString config.users.groups.${cfg.sharefs.group}.gid}"
];
};
};
in in
{ {
imports = [ (import ./options.nix packages) ]; imports = [ (import ./options.nix packages) ];
options = {
# Forward declare a dummy option for VM filesystems since the real one won't exist
# unless the VM module is actually imported.
virtualisation.fileSystems = lib.mkOption { };
};
config = mkIf cfg.enable { config = mkIf cfg.enable {
assertions = [ assertions = [
( (
@@ -66,6 +93,10 @@ in
) "" cfg.users; ) "" cfg.users;
}; };
environment.systemPackages = optional (cfg.sharefs.source != null) cfg.sharefs.package;
fileSystems = mountpoints;
virtualisation.fileSystems = mountpoints;
home-manager = home-manager =
let let
privPackages = mapAttrs (_: userid: { privPackages = mapAttrs (_: userid: {
@@ -322,25 +353,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
); );
}; };
}; };

View File

@@ -809,6 +809,96 @@ package
## environment\.hakurei\.sharefs\.package
The sharefs package to use\.
*Type:*
package
*Default:*
` <derivation sharefs> `
## environment\.hakurei\.sharefs\.group
Name of the group to run the sharefs daemon as\.
*Type:*
string
*Default:*
` "sharefs" `
## environment\.hakurei\.sharefs\.name
Host path to mount sharefs on\.
*Type:*
string
*Default:*
` "/sdcard" `
## environment\.hakurei\.sharefs\.source
Writable backing directory\. Setting this to null disables sharefs\.
*Type:*
null or string
*Default:*
` null `
## environment\.hakurei\.sharefs\.user
Name of the user to run the sharefs daemon as\.
*Type:*
string
*Default:*
` "sharefs" `
## environment\.hakurei\.shell ## environment\.hakurei\.shell

View File

@@ -40,6 +40,46 @@ in
''; '';
}; };
sharefs = {
package = mkOption {
type = types.package;
default = packages.${pkgs.stdenv.hostPlatform.system}.sharefs;
description = "The sharefs package to use.";
};
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

View File

@@ -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
] ]

View File

@@ -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" = {