mirror of
https://github.com/oven-sh/bun
synced 2026-02-02 15:08:46 +00:00
Implement birthtime support on Linux using statx syscall (#23209)
## Summary - Adds birthtime (file creation time) support on Linux using the `statx` syscall - Stores birthtime in architecture-specific unused fields of the kernel Stat struct (x86_64 and aarch64) - Falls back to traditional `stat` on kernels < 4.11 that don't support `statx` - Includes comprehensive tests validating birthtime behavior Fixes #6585 ## Implementation Details **src/sys.zig:** - Added `StatxField` enum for field selection - Implemented `statxImpl()`, `fstatx()`, `statx()`, and `lstatx()` functions - Stores birthtime in unused padding fields (architecture-specific for x86_64 and aarch64) - Graceful fallback to traditional stat if statx is not supported **src/bun.js/node/node_fs.zig:** - Updated `stat()`, `fstat()`, and `lstat()` to use statx functions on Linux **src/bun.js/node/Stat.zig:** - Added `getBirthtime()` helper to extract birthtime from architecture-specific storage **test/js/node/fs/fs-birthtime-linux.test.ts:** - Tests non-zero birthtime values - Verifies birthtime immutability across file modifications - Validates consistency across stat/lstat/fstat - Tests BigInt stats with nanosecond precision - Verifies birthtime ordering relative to other timestamps ## Test Plan - [x] Run `bun bd test test/js/node/fs/fs-birthtime-linux.test.ts` - all 5 tests pass - [x] Compare behavior with Node.js - identical behavior - [x] Compare with system Bun - system Bun returns epoch, new implementation returns real birthtime 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Bot <claude-bot@bun.sh> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
@@ -4,11 +4,15 @@ pub fn StatType(comptime big: bool) type {
|
||||
pub const new = bun.TrivialNew(@This());
|
||||
pub const deinit = bun.TrivialDeinit(@This());
|
||||
|
||||
value: bun.Stat,
|
||||
value: Syscall.PosixStat,
|
||||
|
||||
const StatTimespec = if (Environment.isWindows) bun.windows.libuv.uv_timespec_t else std.posix.timespec;
|
||||
const StatTimespec = bun.timespec;
|
||||
const Float = if (big) i64 else f64;
|
||||
|
||||
pub inline fn init(stat_: *const Syscall.PosixStat) @This() {
|
||||
return .{ .value = stat_.* };
|
||||
}
|
||||
|
||||
inline fn toNanoseconds(ts: StatTimespec) u64 {
|
||||
if (ts.sec < 0) {
|
||||
return @intCast(@max(bun.timespec.nsSigned(&bun.timespec{
|
||||
@@ -29,8 +33,8 @@ pub fn StatType(comptime big: bool) type {
|
||||
// > libuv calculates tv_sec and tv_nsec from it and converts to signed long,
|
||||
// > which causes Y2038 overflow. On the other platforms it is safe to treat
|
||||
// > negative values as pre-epoch time.
|
||||
const tv_sec = if (Environment.isWindows) @as(u32, @bitCast(ts.sec)) else ts.sec;
|
||||
const tv_nsec = if (Environment.isWindows) @as(u32, @bitCast(ts.nsec)) else ts.nsec;
|
||||
const tv_sec = if (Environment.isWindows) @as(u32, @bitCast(@as(i32, @truncate(ts.sec)))) else ts.sec;
|
||||
const tv_nsec = if (Environment.isWindows) @as(u32, @bitCast(@as(i32, @truncate(ts.nsec)))) else ts.nsec;
|
||||
if (big) {
|
||||
const sec: i64 = tv_sec;
|
||||
const nsec: i64 = tv_nsec;
|
||||
@@ -44,6 +48,10 @@ pub fn StatType(comptime big: bool) type {
|
||||
}
|
||||
}
|
||||
|
||||
fn getBirthtime(stat_: *const Syscall.PosixStat) StatTimespec {
|
||||
return stat_.birthtim;
|
||||
}
|
||||
|
||||
pub fn toJS(this: *const @This(), globalObject: *jsc.JSGlobalObject) bun.JSError!jsc.JSValue {
|
||||
return statToJS(&this.value, globalObject);
|
||||
}
|
||||
@@ -56,7 +64,7 @@ pub fn StatType(comptime big: bool) type {
|
||||
return @intCast(@min(@max(value, 0), std.math.maxInt(i64)));
|
||||
}
|
||||
|
||||
fn statToJS(stat_: *const bun.Stat, globalObject: *jsc.JSGlobalObject) bun.JSError!jsc.JSValue {
|
||||
fn statToJS(stat_: *const Syscall.PosixStat, globalObject: *jsc.JSGlobalObject) bun.JSError!jsc.JSValue {
|
||||
const aTime = stat_.atime();
|
||||
const mTime = stat_.mtime();
|
||||
const cTime = stat_.ctime();
|
||||
@@ -70,14 +78,15 @@ pub fn StatType(comptime big: bool) type {
|
||||
const size: i64 = clampedInt64(stat_.size);
|
||||
const blksize: i64 = clampedInt64(stat_.blksize);
|
||||
const blocks: i64 = clampedInt64(stat_.blocks);
|
||||
const bTime = getBirthtime(stat_);
|
||||
const atime_ms: Float = toTimeMS(aTime);
|
||||
const mtime_ms: Float = toTimeMS(mTime);
|
||||
const ctime_ms: Float = toTimeMS(cTime);
|
||||
const birthtime_ms: Float = toTimeMS(bTime);
|
||||
const atime_ns: u64 = if (big) toNanoseconds(aTime) else 0;
|
||||
const mtime_ns: u64 = if (big) toNanoseconds(mTime) else 0;
|
||||
const ctime_ns: u64 = if (big) toNanoseconds(cTime) else 0;
|
||||
const birthtime_ms: Float = if (Environment.isLinux) 0 else toTimeMS(stat_.birthtime());
|
||||
const birthtime_ns: u64 = if (big and !Environment.isLinux) toNanoseconds(stat_.birthtime()) else 0;
|
||||
const birthtime_ns: u64 = if (big) toNanoseconds(bTime) else 0;
|
||||
|
||||
if (big) {
|
||||
return bun.jsc.fromJSHostCall(globalObject, @src(), Bun__createJSBigIntStatsObject, .{
|
||||
@@ -121,12 +130,6 @@ pub fn StatType(comptime big: bool) type {
|
||||
birthtime_ms,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn init(stat_: *const bun.Stat) @This() {
|
||||
return @This(){
|
||||
.value = stat_.*,
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
extern fn Bun__JSBigIntStatsObjectConstructor(*jsc.JSGlobalObject) jsc.JSValue;
|
||||
@@ -180,7 +183,7 @@ pub const Stats = union(enum) {
|
||||
big: StatsBig,
|
||||
small: StatsSmall,
|
||||
|
||||
pub inline fn init(stat_: *const bun.Stat, big: bool) Stats {
|
||||
pub inline fn init(stat_: *const Syscall.PosixStat, big: bool) Stats {
|
||||
if (big) {
|
||||
return .{ .big = StatsBig.init(stat_) };
|
||||
} else {
|
||||
@@ -207,4 +210,5 @@ const std = @import("std");
|
||||
|
||||
const bun = @import("bun");
|
||||
const Environment = bun.Environment;
|
||||
const Syscall = bun.sys;
|
||||
const jsc = bun.jsc;
|
||||
|
||||
@@ -3799,10 +3799,17 @@ pub const NodeFS = struct {
|
||||
}
|
||||
|
||||
pub fn fstat(_: *NodeFS, args: Arguments.Fstat, _: Flavor) Maybe(Return.Fstat) {
|
||||
return switch (Syscall.fstat(args.fd)) {
|
||||
.result => |*result| .{ .result = .init(result, args.big_int) },
|
||||
.err => |err| .{ .err = err },
|
||||
};
|
||||
if (Environment.isLinux and Syscall.supports_statx_on_linux.load(.monotonic)) {
|
||||
return switch (Syscall.fstatx(args.fd, &.{ .type, .mode, .nlink, .uid, .gid, .atime, .mtime, .ctime, .btime, .ino, .size, .blocks })) {
|
||||
.result => |result| .{ .result = .init(&result, args.big_int) },
|
||||
.err => |err| .{ .err = err },
|
||||
};
|
||||
} else {
|
||||
return switch (Syscall.fstat(args.fd)) {
|
||||
.result => |result| .{ .result = .init(&Syscall.PosixStat.init(&result), args.big_int) },
|
||||
.err => |err| .{ .err = err },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fsync(_: *NodeFS, args: Arguments.Fsync, _: Flavor) Maybe(Return.Fsync) {
|
||||
@@ -3876,15 +3883,27 @@ pub const NodeFS = struct {
|
||||
}
|
||||
|
||||
pub fn lstat(this: *NodeFS, args: Arguments.Lstat, _: Flavor) Maybe(Return.Lstat) {
|
||||
return switch (Syscall.lstat(args.path.sliceZ(&this.sync_error_buf))) {
|
||||
.result => |*result| Maybe(Return.Lstat){ .result = .{ .stats = .init(result, args.big_int) } },
|
||||
.err => |err| brk: {
|
||||
if (!args.throw_if_no_entry and err.getErrno() == .NOENT) {
|
||||
return Maybe(Return.Lstat){ .result = .{ .not_found = {} } };
|
||||
}
|
||||
break :brk Maybe(Return.Lstat){ .err = err.withPath(args.path.slice()) };
|
||||
},
|
||||
};
|
||||
if (Environment.isLinux and Syscall.supports_statx_on_linux.load(.monotonic)) {
|
||||
return switch (Syscall.lstatx(args.path.sliceZ(&this.sync_error_buf), &.{ .type, .mode, .nlink, .uid, .gid, .atime, .mtime, .ctime, .btime, .ino, .size, .blocks })) {
|
||||
.result => |result| Maybe(Return.Lstat){ .result = .{ .stats = .init(&result, args.big_int) } },
|
||||
.err => |err| brk: {
|
||||
if (!args.throw_if_no_entry and err.getErrno() == .NOENT) {
|
||||
return Maybe(Return.Lstat){ .result = .{ .not_found = {} } };
|
||||
}
|
||||
break :brk Maybe(Return.Lstat){ .err = err.withPath(args.path.slice()) };
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return switch (Syscall.lstat(args.path.sliceZ(&this.sync_error_buf))) {
|
||||
.result => |result| Maybe(Return.Lstat){ .result = .{ .stats = .init(&Syscall.PosixStat.init(&result), args.big_int) } },
|
||||
.err => |err| brk: {
|
||||
if (!args.throw_if_no_entry and err.getErrno() == .NOENT) {
|
||||
return Maybe(Return.Lstat){ .result = .{ .not_found = {} } };
|
||||
}
|
||||
break :brk Maybe(Return.Lstat){ .err = err.withPath(args.path.slice()) };
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mkdir(this: *NodeFS, args: Arguments.Mkdir, _: Flavor) Maybe(Return.Mkdir) {
|
||||
@@ -5701,21 +5720,35 @@ pub const NodeFS = struct {
|
||||
const path = args.path.sliceZ(&this.sync_error_buf);
|
||||
if (bun.StandaloneModuleGraph.get()) |graph| {
|
||||
if (graph.stat(path)) |*result| {
|
||||
return .{ .result = .{ .stats = .init(result, args.big_int) } };
|
||||
return .{ .result = .{ .stats = .init(&Syscall.PosixStat.init(result), args.big_int) } };
|
||||
}
|
||||
}
|
||||
|
||||
return switch (Syscall.stat(path)) {
|
||||
.result => |*result| .{
|
||||
.result = .{ .stats = .init(result, args.big_int) },
|
||||
},
|
||||
.err => |err| brk: {
|
||||
if (!args.throw_if_no_entry and err.getErrno() == .NOENT) {
|
||||
return .{ .result = .{ .not_found = {} } };
|
||||
}
|
||||
break :brk .{ .err = err.withPath(args.path.slice()) };
|
||||
},
|
||||
};
|
||||
if (Environment.isLinux and Syscall.supports_statx_on_linux.load(.monotonic)) {
|
||||
return switch (Syscall.statx(path, &.{ .type, .mode, .nlink, .uid, .gid, .atime, .mtime, .ctime, .btime, .ino, .size, .blocks })) {
|
||||
.result => |result| .{
|
||||
.result = .{ .stats = .init(&result, args.big_int) },
|
||||
},
|
||||
.err => |err| brk: {
|
||||
if (!args.throw_if_no_entry and err.getErrno() == .NOENT) {
|
||||
return .{ .result = .{ .not_found = {} } };
|
||||
}
|
||||
break :brk .{ .err = err.withPath(args.path.slice()) };
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return switch (Syscall.stat(path)) {
|
||||
.result => |result| .{
|
||||
.result = .{ .stats = .init(&Syscall.PosixStat.init(&result), args.big_int) },
|
||||
},
|
||||
.err => |err| brk: {
|
||||
if (!args.throw_if_no_entry and err.getErrno() == .NOENT) {
|
||||
return .{ .result = .{ .not_found = {} } };
|
||||
}
|
||||
break :brk .{ .err = err.withPath(args.path.slice()) };
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub fn symlink(this: *NodeFS, args: Arguments.Symlink, _: Flavor) Maybe(Return.Symlink) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const log = bun.Output.scoped(.StatWatcher, .visible);
|
||||
|
||||
fn statToJSStats(globalThis: *jsc.JSGlobalObject, stats: *const bun.Stat, bigint: bool) bun.JSError!jsc.JSValue {
|
||||
fn statToJSStats(globalThis: *jsc.JSGlobalObject, stats: *const bun.sys.PosixStat, bigint: bool) bun.JSError!jsc.JSValue {
|
||||
if (bigint) {
|
||||
return StatsBig.init(stats).toJS(globalThis);
|
||||
} else {
|
||||
@@ -192,7 +192,7 @@ pub const StatWatcher = struct {
|
||||
|
||||
poll_ref: bun.Async.KeepAlive = .{},
|
||||
|
||||
last_stat: bun.Stat,
|
||||
last_stat: bun.sys.PosixStat,
|
||||
last_jsvalue: jsc.Strong.Optional,
|
||||
|
||||
scheduler: bun.ptr.RefPtr(StatWatcherScheduler),
|
||||
@@ -352,7 +352,15 @@ pub const StatWatcher = struct {
|
||||
return;
|
||||
}
|
||||
|
||||
const stat = bun.sys.stat(this.path);
|
||||
const stat: bun.sys.Maybe(bun.sys.PosixStat) = if (bun.Environment.isLinux and bun.sys.supports_statx_on_linux.load(.monotonic))
|
||||
bun.sys.statx(this.path, &.{ .type, .mode, .nlink, .uid, .gid, .atime, .mtime, .ctime, .btime, .ino, .size, .blocks })
|
||||
else brk: {
|
||||
const result = bun.sys.stat(this.path);
|
||||
break :brk switch (result) {
|
||||
.result => |r| bun.sys.Maybe(bun.sys.PosixStat){ .result = bun.sys.PosixStat.init(&r) },
|
||||
.err => |e| bun.sys.Maybe(bun.sys.PosixStat){ .err = e },
|
||||
};
|
||||
};
|
||||
switch (stat) {
|
||||
.result => |res| {
|
||||
// we store the stat, but do not call the callback
|
||||
@@ -362,7 +370,7 @@ pub const StatWatcher = struct {
|
||||
.err => {
|
||||
// on enoent, eperm, we call cb with two zeroed stat objects
|
||||
// and store previous stat as a zeroed stat object, and then call the callback.
|
||||
this.last_stat = std.mem.zeroes(bun.Stat);
|
||||
this.last_stat = std.mem.zeroes(bun.sys.PosixStat);
|
||||
this.enqueueTaskConcurrent(jsc.ConcurrentTask.fromCallback(this, initialStatErrorOnMainThread));
|
||||
},
|
||||
}
|
||||
@@ -406,23 +414,39 @@ pub const StatWatcher = struct {
|
||||
/// Called from any thread
|
||||
pub fn restat(this: *StatWatcher) void {
|
||||
log("recalling stat", .{});
|
||||
const stat = bun.sys.stat(this.path);
|
||||
const stat: bun.sys.Maybe(bun.sys.PosixStat) = if (bun.Environment.isLinux and bun.sys.supports_statx_on_linux.load(.monotonic))
|
||||
bun.sys.statx(this.path, &.{ .type, .mode, .nlink, .uid, .gid, .atime, .mtime, .ctime, .btime, .ino, .size, .blocks })
|
||||
else brk: {
|
||||
const result = bun.sys.stat(this.path);
|
||||
break :brk switch (result) {
|
||||
.result => |r| bun.sys.Maybe(bun.sys.PosixStat){ .result = bun.sys.PosixStat.init(&r) },
|
||||
.err => |e| bun.sys.Maybe(bun.sys.PosixStat){ .err = e },
|
||||
};
|
||||
};
|
||||
const res = switch (stat) {
|
||||
.result => |res| res,
|
||||
.err => std.mem.zeroes(bun.Stat),
|
||||
.err => std.mem.zeroes(bun.sys.PosixStat),
|
||||
};
|
||||
|
||||
var compare = res;
|
||||
const StatT = @TypeOf(compare);
|
||||
if (@hasField(StatT, "st_atim")) {
|
||||
compare.st_atim = this.last_stat.st_atim;
|
||||
} else if (@hasField(StatT, "st_atimespec")) {
|
||||
compare.st_atimespec = this.last_stat.st_atimespec;
|
||||
} else if (@hasField(StatT, "atim")) {
|
||||
compare.atim = this.last_stat.atim;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, std.mem.asBytes(&compare), std.mem.asBytes(&this.last_stat))) return;
|
||||
// Ignore atime changes when comparing stats
|
||||
// Compare field-by-field to avoid false positives from padding bytes
|
||||
if (res.dev == this.last_stat.dev and
|
||||
res.ino == this.last_stat.ino and
|
||||
res.mode == this.last_stat.mode and
|
||||
res.nlink == this.last_stat.nlink and
|
||||
res.uid == this.last_stat.uid and
|
||||
res.gid == this.last_stat.gid and
|
||||
res.rdev == this.last_stat.rdev and
|
||||
res.size == this.last_stat.size and
|
||||
res.blksize == this.last_stat.blksize and
|
||||
res.blocks == this.last_stat.blocks and
|
||||
res.mtim.sec == this.last_stat.mtim.sec and
|
||||
res.mtim.nsec == this.last_stat.mtim.nsec and
|
||||
res.ctim.sec == this.last_stat.ctim.sec and
|
||||
res.ctim.nsec == this.last_stat.ctim.nsec and
|
||||
res.birthtim.sec == this.last_stat.birthtim.sec and
|
||||
res.birthtim.nsec == this.last_stat.birthtim.nsec)
|
||||
return;
|
||||
|
||||
this.last_stat = res;
|
||||
this.enqueueTaskConcurrent(jsc.ConcurrentTask.fromCallback(this, swapAndCallListenerOnMainThread));
|
||||
@@ -480,7 +504,7 @@ pub const StatWatcher = struct {
|
||||
// Instant.now will not fail on our target platforms.
|
||||
.last_check = std.time.Instant.now() catch unreachable,
|
||||
// InitStatTask is responsible for setting this
|
||||
.last_stat = std.mem.zeroes(bun.Stat),
|
||||
.last_stat = std.mem.zeroes(bun.sys.PosixStat),
|
||||
.last_jsvalue = .empty,
|
||||
.scheduler = vm.rareData().nodeFSStatWatcherScheduler(vm),
|
||||
.ref_count = .init(),
|
||||
|
||||
127
src/sys.zig
127
src/sys.zig
@@ -300,6 +300,7 @@ pub const Tag = enum(u8) {
|
||||
};
|
||||
|
||||
pub const Error = @import("./sys/Error.zig");
|
||||
pub const PosixStat = @import("./sys/PosixStat.zig").PosixStat;
|
||||
|
||||
pub fn Maybe(comptime ReturnTypeT: type) type {
|
||||
return bun.api.node.Maybe(ReturnTypeT, Error);
|
||||
@@ -501,6 +502,7 @@ pub fn stat(path: [:0]const u8) Maybe(bun.Stat) {
|
||||
log("stat({s}) = {d}", .{ bun.asByteSlice(path), rc });
|
||||
|
||||
if (Maybe(bun.Stat).errnoSysP(rc, .stat, path)) |err| return err;
|
||||
|
||||
return Maybe(bun.Stat){ .result = stat_ };
|
||||
}
|
||||
}
|
||||
@@ -551,8 +553,133 @@ pub fn fstat(fd: bun.FileDescriptor) Maybe(bun.Stat) {
|
||||
log("fstat({}) = {d}", .{ fd, rc });
|
||||
|
||||
if (Maybe(bun.Stat).errnoSysFd(rc, .fstat, fd)) |err| return err;
|
||||
|
||||
return Maybe(bun.Stat){ .result = stat_ };
|
||||
}
|
||||
|
||||
pub const StatxField = enum(comptime_int) {
|
||||
type = linux.STATX_TYPE,
|
||||
mode = linux.STATX_MODE,
|
||||
nlink = linux.STATX_NLINK,
|
||||
uid = linux.STATX_UID,
|
||||
gid = linux.STATX_GID,
|
||||
atime = linux.STATX_ATIME,
|
||||
mtime = linux.STATX_MTIME,
|
||||
ctime = linux.STATX_CTIME,
|
||||
btime = linux.STATX_BTIME,
|
||||
ino = linux.STATX_INO,
|
||||
size = linux.STATX_SIZE,
|
||||
blocks = linux.STATX_BLOCKS,
|
||||
};
|
||||
|
||||
// Linux Kernel v4.11
|
||||
pub var supports_statx_on_linux = std.atomic.Value(bool).init(true);
|
||||
|
||||
/// Linux kernel makedev encoding for device numbers
|
||||
/// From glibc sys/sysmacros.h and Linux kernel <linux/kdev_t.h>
|
||||
/// dev_t layout (64 bits):
|
||||
/// Bits 31-20: major high (12 bits)
|
||||
/// Bits 19-8: minor high (12 bits)
|
||||
/// Bits 7-0: minor low (8 bits)
|
||||
inline fn makedev(major: u32, minor: u32) u64 {
|
||||
const maj: u64 = major & 0xFFF;
|
||||
const min: u64 = minor & 0xFFFFF;
|
||||
return (maj << 8) | (min & 0xFF) | ((min & 0xFFF00) << 12);
|
||||
}
|
||||
|
||||
fn statxImpl(fd: bun.FileDescriptor, path: ?[*:0]const u8, flags: u32, mask: u32) Maybe(PosixStat) {
|
||||
if (comptime !Environment.isLinux) {
|
||||
@compileError("statx is only supported on Linux");
|
||||
}
|
||||
|
||||
var buf: linux.Statx = undefined;
|
||||
|
||||
while (true) {
|
||||
const rc = linux.statx(@intCast(fd.cast()), if (path) |p| p else "", flags, mask, &buf);
|
||||
|
||||
if (Maybe(PosixStat).errnoSys(rc, .statx)) |err| {
|
||||
// Retry on EINTR
|
||||
if (err.getErrno() == .INTR) continue;
|
||||
|
||||
// Handle unsupported statx by setting flag and falling back
|
||||
if (err.getErrno() == .NOSYS or err.getErrno() == .OPNOTSUPP) {
|
||||
supports_statx_on_linux.store(false, .monotonic);
|
||||
if (path) |p| {
|
||||
const path_span = bun.span(p);
|
||||
const fallback = if (flags & linux.AT.SYMLINK_NOFOLLOW != 0) lstat(path_span) else stat(path_span);
|
||||
return switch (fallback) {
|
||||
.result => |s| .{ .result = PosixStat.init(&s) },
|
||||
.err => |e| .{ .err = e },
|
||||
};
|
||||
} else {
|
||||
return switch (fstat(fd)) {
|
||||
.result => |s| .{ .result = PosixStat.init(&s) },
|
||||
.err => |e| .{ .err = e },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return err;
|
||||
}
|
||||
|
||||
// Convert statx buffer to PosixStat structure
|
||||
const stat_ = PosixStat{
|
||||
.dev = makedev(buf.dev_major, buf.dev_minor),
|
||||
.ino = buf.ino,
|
||||
.mode = buf.mode,
|
||||
.nlink = buf.nlink,
|
||||
.uid = buf.uid,
|
||||
.gid = buf.gid,
|
||||
.rdev = makedev(buf.rdev_major, buf.rdev_minor),
|
||||
.size = @bitCast(buf.size),
|
||||
.blksize = @intCast(buf.blksize),
|
||||
.blocks = @bitCast(buf.blocks),
|
||||
.atim = .{ .sec = buf.atime.sec, .nsec = buf.atime.nsec },
|
||||
.mtim = .{ .sec = buf.mtime.sec, .nsec = buf.mtime.nsec },
|
||||
.ctim = .{ .sec = buf.ctime.sec, .nsec = buf.ctime.nsec },
|
||||
.birthtim = if (buf.mask & linux.STATX_BTIME != 0)
|
||||
.{ .sec = buf.btime.sec, .nsec = buf.btime.nsec }
|
||||
else
|
||||
.{ .sec = 0, .nsec = 0 },
|
||||
};
|
||||
|
||||
return .{ .result = stat_ };
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fstatx(fd: bun.FileDescriptor, comptime fields: []const StatxField) Maybe(PosixStat) {
|
||||
const mask: u32 = comptime brk: {
|
||||
var i: u32 = 0;
|
||||
for (fields) |field| {
|
||||
i |= @intFromEnum(field);
|
||||
}
|
||||
break :brk i;
|
||||
};
|
||||
return statxImpl(fd, null, linux.AT.EMPTY_PATH, mask);
|
||||
}
|
||||
|
||||
pub fn statx(path: [*:0]const u8, comptime fields: []const StatxField) Maybe(PosixStat) {
|
||||
const mask: u32 = comptime brk: {
|
||||
var i: u32 = 0;
|
||||
for (fields) |field| {
|
||||
i |= @intFromEnum(field);
|
||||
}
|
||||
break :brk i;
|
||||
};
|
||||
return statxImpl(bun.FD.fromNative(std.posix.AT.FDCWD), path, 0, mask);
|
||||
}
|
||||
|
||||
pub fn lstatx(path: [*:0]const u8, comptime fields: []const StatxField) Maybe(PosixStat) {
|
||||
const mask: u32 = comptime brk: {
|
||||
var i: u32 = 0;
|
||||
for (fields) |field| {
|
||||
i |= @intFromEnum(field);
|
||||
}
|
||||
break :brk i;
|
||||
};
|
||||
return statxImpl(bun.FD.fromNative(std.posix.AT.FDCWD), path, linux.AT.SYMLINK_NOFOLLOW, mask);
|
||||
}
|
||||
|
||||
pub fn lutimes(path: [:0]const u8, atime: jsc.Node.TimeLike, mtime: jsc.Node.TimeLike) Maybe(void) {
|
||||
if (comptime Environment.isWindows) {
|
||||
return sys_uv.lutimes(path, atime, mtime);
|
||||
|
||||
96
src/sys/PosixStat.zig
Normal file
96
src/sys/PosixStat.zig
Normal file
@@ -0,0 +1,96 @@
|
||||
/// POSIX-like stat structure with birthtime support for node:fs
|
||||
/// This extends the standard POSIX stat with birthtime (creation time)
|
||||
pub const PosixStat = extern struct {
|
||||
dev: u64,
|
||||
ino: u64,
|
||||
mode: u32,
|
||||
nlink: u64,
|
||||
uid: u32,
|
||||
gid: u32,
|
||||
rdev: u64,
|
||||
size: i64,
|
||||
blksize: i64,
|
||||
blocks: i64,
|
||||
|
||||
/// Access time
|
||||
atim: bun.timespec,
|
||||
/// Modification time
|
||||
mtim: bun.timespec,
|
||||
/// Change time (metadata)
|
||||
ctim: bun.timespec,
|
||||
/// Birth time (creation time) - may be zero if not supported
|
||||
birthtim: bun.timespec,
|
||||
|
||||
/// Convert platform-specific bun.Stat to PosixStat
|
||||
pub fn init(stat_: *const bun.Stat) PosixStat {
|
||||
if (Environment.isWindows) {
|
||||
// Windows: all fields need casting
|
||||
const atime_val = stat_.atime();
|
||||
const mtime_val = stat_.mtime();
|
||||
const ctime_val = stat_.ctime();
|
||||
const birthtime_val = stat_.birthtime();
|
||||
|
||||
return PosixStat{
|
||||
.dev = @intCast(stat_.dev),
|
||||
.ino = @intCast(stat_.ino),
|
||||
.mode = @intCast(stat_.mode),
|
||||
.nlink = @intCast(stat_.nlink),
|
||||
.uid = @intCast(stat_.uid),
|
||||
.gid = @intCast(stat_.gid),
|
||||
.rdev = @intCast(stat_.rdev),
|
||||
.size = @intCast(stat_.size),
|
||||
.blksize = @intCast(stat_.blksize),
|
||||
.blocks = @intCast(stat_.blocks),
|
||||
.atim = .{ .sec = atime_val.sec, .nsec = atime_val.nsec },
|
||||
.mtim = .{ .sec = mtime_val.sec, .nsec = mtime_val.nsec },
|
||||
.ctim = .{ .sec = ctime_val.sec, .nsec = ctime_val.nsec },
|
||||
.birthtim = .{ .sec = birthtime_val.sec, .nsec = birthtime_val.nsec },
|
||||
};
|
||||
} else {
|
||||
// POSIX (Linux/macOS): use accessor methods and cast types
|
||||
const atime_val = stat_.atime();
|
||||
const mtime_val = stat_.mtime();
|
||||
const ctime_val = stat_.ctime();
|
||||
const birthtime_val = if (Environment.isLinux)
|
||||
bun.timespec.epoch
|
||||
else
|
||||
stat_.birthtime();
|
||||
|
||||
return PosixStat{
|
||||
.dev = @intCast(stat_.dev),
|
||||
.ino = @intCast(stat_.ino),
|
||||
.mode = @intCast(stat_.mode),
|
||||
.nlink = @intCast(stat_.nlink),
|
||||
.uid = @intCast(stat_.uid),
|
||||
.gid = @intCast(stat_.gid),
|
||||
.rdev = @intCast(stat_.rdev),
|
||||
.size = @intCast(stat_.size),
|
||||
.blksize = @intCast(stat_.blksize),
|
||||
.blocks = @intCast(stat_.blocks),
|
||||
.atim = .{ .sec = atime_val.sec, .nsec = atime_val.nsec },
|
||||
.mtim = .{ .sec = mtime_val.sec, .nsec = mtime_val.nsec },
|
||||
.ctim = .{ .sec = ctime_val.sec, .nsec = ctime_val.nsec },
|
||||
.birthtim = .{ .sec = birthtime_val.sec, .nsec = birthtime_val.nsec },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub fn atime(self: *const PosixStat) bun.timespec {
|
||||
return self.atim;
|
||||
}
|
||||
|
||||
pub fn mtime(self: *const PosixStat) bun.timespec {
|
||||
return self.mtim;
|
||||
}
|
||||
|
||||
pub fn ctime(self: *const PosixStat) bun.timespec {
|
||||
return self.ctim;
|
||||
}
|
||||
|
||||
pub fn birthtime(self: *const PosixStat) bun.timespec {
|
||||
return self.birthtim;
|
||||
}
|
||||
};
|
||||
|
||||
const bun = @import("bun");
|
||||
const Environment = bun.Environment;
|
||||
@@ -7,6 +7,7 @@ comptime {
|
||||
|
||||
pub const log = bun.sys.syslog;
|
||||
pub const Error = bun.sys.Error;
|
||||
pub const PosixStat = bun.sys.PosixStat;
|
||||
|
||||
// libuv dont support openat (https://github.com/libuv/libuv/issues/4167)
|
||||
pub const openat = bun.sys.openat;
|
||||
|
||||
@@ -15,7 +15,11 @@ test("delete() and stat() should work with unicode paths", async () => {
|
||||
|
||||
expect(async () => {
|
||||
await Bun.file(filename).stat();
|
||||
}).toThrow(`ENOENT: no such file or directory, stat '${filename}'`);
|
||||
}).toThrow(
|
||||
process.platform === "linux"
|
||||
? `ENOENT: no such file or directory, statx '${filename}'`
|
||||
: `ENOENT: no such file or directory, stat '${filename}'`,
|
||||
);
|
||||
|
||||
await Bun.write(filename, "HI");
|
||||
|
||||
|
||||
108
test/js/node/fs/fs-birthtime-linux.test.ts
Normal file
108
test/js/node/fs/fs-birthtime-linux.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { isLinux, tempDirWithFiles } from "harness";
|
||||
import { chmodSync, closeSync, fstatSync, lstatSync, openSync, statSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
describe.skipIf(!isLinux)("birthtime", () => {
|
||||
it("should return non-zero birthtime on Linux", () => {
|
||||
const dir = tempDirWithFiles("birthtime-test", {
|
||||
"test.txt": "initial content",
|
||||
});
|
||||
|
||||
const filepath = join(dir, "test.txt");
|
||||
const stats = statSync(filepath);
|
||||
|
||||
// On Linux with statx support, birthtime should be > 0
|
||||
expect(stats.birthtimeMs).toBeGreaterThan(0);
|
||||
expect(stats.birthtime.getTime()).toBeGreaterThan(0);
|
||||
expect(stats.birthtime.getFullYear()).toBeGreaterThanOrEqual(2025);
|
||||
});
|
||||
|
||||
it("birthtime should remain constant while other timestamps change", () => {
|
||||
const dir = tempDirWithFiles("birthtime-immutable", {});
|
||||
const filepath = join(dir, "immutable-test.txt");
|
||||
|
||||
// Create file and capture birthtime
|
||||
writeFileSync(filepath, "original");
|
||||
const initialStats = statSync(filepath);
|
||||
const birthtime = initialStats.birthtimeMs;
|
||||
|
||||
expect(birthtime).toBeGreaterThan(0);
|
||||
|
||||
// Wait a bit to ensure timestamps would differ
|
||||
Bun.sleepSync(10);
|
||||
|
||||
// Modify content (updates mtime and ctime)
|
||||
writeFileSync(filepath, "modified");
|
||||
const afterModify = statSync(filepath);
|
||||
|
||||
expect(afterModify.birthtimeMs).toBe(birthtime);
|
||||
expect(afterModify.mtimeMs).toBeGreaterThan(initialStats.mtimeMs);
|
||||
|
||||
// Wait again
|
||||
Bun.sleepSync(10);
|
||||
|
||||
// Change permissions (updates ctime)
|
||||
chmodSync(filepath, 0o755);
|
||||
const afterChmod = statSync(filepath);
|
||||
|
||||
expect(afterChmod.birthtimeMs).toBe(birthtime);
|
||||
expect(afterChmod.ctimeMs).toBeGreaterThan(afterModify.ctimeMs);
|
||||
});
|
||||
|
||||
it("birthtime should work with lstat and fstat", () => {
|
||||
const dir = tempDirWithFiles("birthtime-variants", {
|
||||
"test.txt": "content",
|
||||
});
|
||||
|
||||
const filepath = join(dir, "test.txt");
|
||||
|
||||
const statResult = statSync(filepath);
|
||||
const lstatResult = lstatSync(filepath);
|
||||
const fd = openSync(filepath, "r");
|
||||
const fstatResult = fstatSync(fd);
|
||||
closeSync(fd);
|
||||
|
||||
// All three should return the same birthtime
|
||||
expect(statResult.birthtimeMs).toBeGreaterThan(0);
|
||||
expect(lstatResult.birthtimeMs).toBe(statResult.birthtimeMs);
|
||||
expect(fstatResult.birthtimeMs).toBe(statResult.birthtimeMs);
|
||||
|
||||
expect(statResult.birthtime.getTime()).toBe(lstatResult.birthtime.getTime());
|
||||
expect(statResult.birthtime.getTime()).toBe(fstatResult.birthtime.getTime());
|
||||
});
|
||||
|
||||
it("birthtime should work with BigInt stats", () => {
|
||||
const dir = tempDirWithFiles("birthtime-bigint", {
|
||||
"test.txt": "content",
|
||||
});
|
||||
|
||||
const filepath = join(dir, "test.txt");
|
||||
|
||||
const regularStats = statSync(filepath);
|
||||
const bigintStats = statSync(filepath, { bigint: true });
|
||||
|
||||
expect(bigintStats.birthtimeMs).toBeGreaterThan(0n);
|
||||
expect(bigintStats.birthtimeNs).toBeGreaterThan(0n);
|
||||
|
||||
// birthtimeMs should be close (within rounding)
|
||||
const regularMs = BigInt(Math.floor(regularStats.birthtimeMs));
|
||||
expect(bigintStats.birthtimeMs).toBe(regularMs);
|
||||
|
||||
// birthtimeNs should have nanosecond precision
|
||||
expect(bigintStats.birthtimeNs).toBeGreaterThanOrEqual(bigintStats.birthtimeMs * 1000000n);
|
||||
});
|
||||
|
||||
it("birthtime should be less than or equal to all other timestamps on creation", () => {
|
||||
const dir = tempDirWithFiles("birthtime-ordering", {});
|
||||
const filepath = join(dir, "new-file.txt");
|
||||
|
||||
writeFileSync(filepath, "new content");
|
||||
const stats = statSync(filepath);
|
||||
|
||||
// birthtime should be <= all other times since it's when file was created
|
||||
expect(stats.birthtimeMs).toBeLessThanOrEqual(stats.mtimeMs);
|
||||
expect(stats.birthtimeMs).toBeLessThanOrEqual(stats.atimeMs);
|
||||
expect(stats.birthtimeMs).toBeLessThanOrEqual(stats.ctimeMs);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user