Description
Problem
When attempting to open a file using std::fs::OpenOptions
with both .read(true)
and .create(true)
set, but without .write(true)
or .append(true)
, the open()
method returns an io::Error
with kind: InvalidInput
(corresponding to EINVAL / OS error 22 on Linux).
My intention was to create a marker file that indicates the process started, without writing anything into it.
MRE:
playground
use std::fs::OpenOptions;
use std::io;
use std::path::Path;
fn main() -> io::Result<()> {
let path = Path::new("/tmp/rust_test_marker_file");
println!("Attempting: OpenOptions::new().read(true).create(true).open(...)");
// This combination fails
let file = OpenOptions::new()
.create(true) // Create if not exists
// .write(true) // NOTE: Adding this makes it work
.open(path);
match file {
Ok(f) => {
println!("Success! Opened file: {:?}", f);
Ok(())
}
Err(e) => {
eprintln!("----------------------------------------");
eprintln!("Failed to open file!");
eprintln!("Error: {:?}", e);
eprintln!("Kind: {:?}", e.kind());
eprintln!("OS Error: {:?}", e.raw_os_error());
eprintln!("----------------------------------------");
Err(e)
}
}
}
Output:
Error: Os { code: 22, kind: InvalidInput, message: "Invalid argument" }
Debugging & Context
Running the above code produces Error: Os { code: 22, kind: InvalidInput, message: "Invalid argument" }.
Because this snippet was part of a larger system, I spent considerable time debugging my logic and, eventually, using strace
to determine what was wrong.
strace
confirmed that the error occurs before any open
or openat
system call is attempted for the target file, indicating that the validation failure happens within the Rust standard library.
Analysis of the std::fs::sys::unix::fs source reveals a check in OpenOptions::get_creation_mode
that explicitly returns EINVAL
if create
, create_new
, or truncate
is set without write
or append
also being set.
Crucially, the underlying Linux syscall does permit this combination, as the following C code demonstrates:
It successfully executes open(path, O_RDONLY | O_CREAT, 0644)
.
Working C Example:
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <sys/stat.h>
#include <libgen.h>
#include <errno.h>
int main() {
const char *path = "/tmp/c_test_marker_file";
char *p = strdup(path);
if (!p) { perror("strdup"); return 1; }
char *dir = dirname(p);
if (dir && strcmp(dir, ".") && strcmp(dir, "/")) {
if (mkdir(dir, 0777) == -1 && errno != EEXIST) {
perror("mkdir"); return 1;
}
}
printf("Attempting open(\"%s\", O_RDONLY | O_CREAT, 0644)\n", path);
int fd = open(path, O_RDONLY | O_CREAT, 0644);
if (fd == -1) {
perror("open");
return 1;
}
printf("Opened file with fd: %d\n", fd);
printf("Calling fsync...\n");
if (fsync(fd) == -1) {
perror("fsync");
} else {
printf("fsync succeeded.\n");
}
return 0;
}
Output:
gcc test.c && ./a.out && ls -lah /tmp/c_test_marker_file
Attempting open("/tmp/c_test_marker_file", O_RDONLY | O_CREAT, 0644)
Opened file with fd: 3
Calling fsync...
fsync succeeded.
-rw-r--r-- 1 user user 0 May 3 17:22 /tmp/c_test_marker_file
Similarly, Python's standard file opening modes can create files for reading (with "a+"
):
import os
path = "/tmp/py_test_marker_file"
parent = os.path.dirname(path)
try:
os.makedirs(parent, exist_ok=True)
except Exception as e:
print("mkdir failed:", e)
exit(1)
print(f'Opening "{path}" for reading/creating...')
try:
f = open(path, "a+")
except Exception as e:
print("open failed:", e)
exit(1)
print(f"Opened file: {f}")
print("Calling fsync...")
try:
f.flush()
os.fsync(f.fileno())
print("fsync succeeded.")
except Exception as e:
print("fsync failed:", e)
Documentation Issue
The current documentation for OpenOptions::open
lists potential errors, including:
InvalidInput: Invalid combinations of open options (truncate without write access, no access mode set, etc.).
While the failing case is considered an invalid combination by the Rust standard library, it is not explicitly listed. The phrase "etc." does not make it clear that requesting creation inherently requires requesting write or append access within the OpenOptions
builder—even though the underlying OS (like Linux) doesn't enforce this restriction for O_RDONLY | O_CREAT
.
This can be surprising for users expecting behavior aligned with OS syscalls.
Note
The .create()
method’s documentation does specify that .write(true)
or .append(true)
must also be set for .create(true)
to actually create a file. However, this restriction is not repeated in the .open()
documentation, nor is it reflected in error messages. This makes it easy to overlook, leading to confusion and unnecessary debugging.
Suggestion
Please consider updating the documentation for OpenOptions::open
and/or the io::ErrorKind::InvalidInput
description to explicitly state that setting .create(true)
, .create_new(true)
, or .truncate(true)
also requires .write(true)
or .append(true)
to be set. This would make the behavior much less surprising for users expecting alignment with the underlying OS.
Alternatively, is this validation check actually necessary? If not, perhaps it could be relaxed to match OS behavior. At minimum, returning a more specific error message (rather than "Invalid argument") would also help prevent user confusion.
upd
removed read from mre because it is set by default