Skip to content

Create and write files whose mode carries no owner-write bit - #65

Open
Ebrathul wants to merge 3 commits into
opencloud-eu:mainfrom
Ebrathul:fix/create-and-fd-write
Open

Create and write files whose mode carries no owner-write bit#65
Ebrathul wants to merge 3 commits into
opencloud-eu:mainfrom
Ebrathul:fix/create-and-fd-write

Conversation

@Ebrathul

@Ebrathul Ebrathul commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

I have been running openvfs on my own account with my own tool and the desktop-client plug-in
from nextcloud/desktop#10635, to find out what still stands between it and
everyday use of a sync root. The first thing that stopped me was files without
an owner-write bit: I could not create one inside the mount, which among other
things means a git repository cannot be written to there.

Creating a file with a read-only mode and writing to the descriptor that created
it fails with EACCES on an openvfs mount. Two independent defects produce the
one symptom, and neither fixes it alone.

There is no create() operation, so libfuse falls back to mknod() plus a
separate open(). openVFSfuse_mknod() creates the file with the mode the
caller asked for — applied literally, because main() calls umask(0) — and
closes it:

if (S_ISREG(mode)) {
    res = open(path.c_str(), O_CREAT | O_EXCL | O_WRONLY, mode);
    ...
    if (res >= 0)
        res = close(res);
}

The open() that follows is then a real permission check against a file that is
already read-only. It fails, and the zero-length file stays behind. On a local
filesystem one open() does both halves, and the mode it is given is never
consulted for the descriptor it returns.

openVFSfuse_write() discards the descriptor and re-opens the backing file
on every call:

    const auto path = getInternalPath(orig_path);
    (void)fi;

    fd = open(path.c_str(), O_WRONLY);

openVFSfuse_read() already reads from fi->fh and openVFSfuse_release()
closes it, so write() was the only operation not using what open() stored.
Besides an open()/close() pair per write call, this re-evaluates the mode on
every write: a descriptor stays writable only as long as a fresh open() would
succeed.

Creating a file read-only and writing through the returned descriptor is
ordinary, not application-specific: git creates every loose object with
git_mkstemp_mode(..., 0444), cp -p reproduces a read-only source mode, and
tar -x restores read-only modes from an archive. Writing to a descriptor after
the file's mode changed is plain POSIX — the mode is checked at open() and not
again.

Reproduction

Creating a file and writing one byte to the returned descriptor, per mode. Same
script on tmpfs for comparison:

        openvfs   tmpfs
0644    OK        OK
0600    OK        OK
0444    EACCES    OK
0400    EACCES    OK

The owner-write bit is the discriminator. Each failure leaves a zero-length file
behind, which is mknod() having already succeeded.

The write half reproduces without creating anything:

fd = os.open(p, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
os.write(fd, b"first\n")
os.chmod(p, 0o444)          # fd is still open for writing
os.write(fd, b"second\n")
tmpfs:   write-after-chmod OK (7 bytes)
openvfs: write-after-chmod FAILED errno=13 Permission denied

In git terms, on a mount inside a sync root:

error: insufficient permission for adding an object to repository database .git/objects
fatal: adding files failed

Reading a repository works — status, log, and a tree copied in with cp -a
are all fine. Only writing fails.

The two defects are independent. With create() implemented but write() left
alone, creation succeeds and the first write fails instead:

create: OK (fd obtained)
write : FAILED errno=13 Permission denied

fatal: unable to write loose object file: Permission denied

Hence one pull request with two commits rather than two pull requests.

The change

create() creates and opens in one step and hands the descriptor back, the way
libfuse's own passthrough.c does — open(path, fi->flags, mode), flags passed
through untouched. The kernel sets O_CREAT itself on a create request, so there
is nothing to add to them; on a mount instrumented to print what arrives:

open(O_CREAT|O_EXCL|O_WRONLY, 0444)  ->  create flags=0100301   O_CREAT set
open(O_CREAT|O_WRONLY,        0644)  ->  create flags=0100101   O_CREAT set

O_EXCL arrives too and is honoured by passing the flags through, which keeps
the race behaviour a caller asked for.

write() reads fi->fh directly rather than hedging on a path-based open,
because read() and release() already assume the same invariant.

The error paths save errno before debug logging, since formatting and syslog
must not change the error returned to FUSE. create() also assigns ownership
through the open descriptor. If that fails, it removes the incomplete file,
closes the descriptor, reports any cleanup failure, and returns the original
ownership error instead of leaving a file with unexpected ownership behind.
The variadic log arguments use types matching their format specifiers.

mknod() is deliberately left alone. After this the ordinary create path no
longer routes through it, and mknod(0444) followed by open(O_WRONLY) failing
is correct behaviour that should keep working.

A file that does not exist yet cannot be a placeholder, so create() needs
neither hydration nor placeholder attributes — getxattr() already synthesises a
hydrated default for a file that carries none.

No test. This bug lives in the FUSE layer, and main has no harness that
mounts a filesystem — ctest builds appstreamtest only. #64 introduces
socketthreadtest, but on the other side of that boundary, and adding a second
parallel harness alongside it seemed wrong. Happy to turn the scripts above into
a test in whatever shape you would like once #64 has settled.

openVFSfuse_truncate() ignores fi the same way write() did and fails the
same way. Separate defect, separate fix, separate pull request.

Relationship to the open pull requests

Independent of #61#64. #64 rewrites the hydration wait inside
openVFSfuse_open() and #61 rewrites the socket read path; neither touches
write(), mknod(), the operations table, nor adds a create(). No ref in this
repository implements create() today.

These commits apply cleanly to main at cbdeeef, and I also cherry-picked them
onto #64's head and built there: ctest green including #64's new
socketthreadtest, and the acceptance list below re-run on that combination with
the same results. Tested, not assumed — whichever of these lands first, the other
still applies.

Environment

Linux 7.1.8 (CachyOS), libfuse 3.18.2, Btrfs.

Applied to a fresh clone of main at cbdeeef and built there. Built
RelWithDebInfo with gcc 16.2.1, gcc 15 and clang 22 — clean on all three,
ctest green, clang-format clean. Under -Wall -Wextra -Wconversion -Wsign-conversion the added lines warn exactly as the existing fi->fh = res in
open(), pread(fi->fh, ...) in read() and close(fi->fh) in release() do,
and no other way.

Exercised against a real sync root: git init, git add and two commits over
250 files succeed, git fsck clean, objects created 0444, cp -p of a
read-only file and tar -x preserving read-only modes both work, O_APPEND
writes and interleaved pwrite offsets stay correct, a 60 MB write is
byte-identical locally and after upload, and a dehydrated file still hydrates on
read to a checksum identical to the server's.

openVFSfuse_write() discarded fuse_file_info and re-opened the backing
file with O_WRONLY on every call. openVFSfuse_read() already reads from
fi->fh and openVFSfuse_release() closes it, so write() was the only
operation not using the descriptor open() had stored.

Two consequences. An open()/close() pair ran per write call. And the
mode bits were re-evaluated on every write, so a descriptor stayed
writable only as long as the file's mode allowed a fresh open: writing
through an fd that was opened before the file was made read-only failed
with EACCES, where POSIX requires it to succeed.

Assisted-by: Claude Opus 5 (Anthropic)
Without a create operation libfuse falls back to mknod() plus a separate
open(). openVFSfuse_mknod() creates the file with the mode the caller
asked for -- applied literally, because main() sets umask(0) -- and
closes it. The open() that follows is then a real permission check
against a file that is already read-only, and fails with EACCES, leaving
a zero-length file behind.

Creating a file with a mode that has no owner-write bit and writing to
the returned descriptor is ordinary: git creates every loose object with
mkstemp mode 0444, and cp -p reproduces the source mode. On a local
filesystem one open() call does both halves and the mode is never
consulted for the descriptor it returns.

Doing the same here means implementing create(), which creates and opens
in one step and hands back the descriptor. mknod() is left alone: after
this change nothing routes an O_CREAT open through it, and mknod(0444)
followed by open(O_WRONLY) failing is correct.

Assisted-by: Claude Opus 5 (Anthropic)
Report ownership failures from create and remove the incomplete file
instead of returning a descriptor with unexpected ownership. Preserve
syscall errors across debug logging and pass correctly typed values to
the variadic logger.

Assisted-by: Codex:GPT-5
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant