diff --git a/src/bun.js/node/Stat.zig b/src/bun.js/node/Stat.zig index 45e3492ec0..1ffb7271b6 100644 --- a/src/bun.js/node/Stat.zig +++ b/src/bun.js/node/Stat.zig @@ -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; diff --git a/src/bun.js/node/node_fs.zig b/src/bun.js/node/node_fs.zig index ffd20551e9..174563b5c7 100644 --- a/src/bun.js/node/node_fs.zig +++ b/src/bun.js/node/node_fs.zig @@ -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) { diff --git a/src/bun.js/node/node_fs_stat_watcher.zig b/src/bun.js/node/node_fs_stat_watcher.zig index c10a391f3f..8ff99bde77 100644 --- a/src/bun.js/node/node_fs_stat_watcher.zig +++ b/src/bun.js/node/node_fs_stat_watcher.zig @@ -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(), diff --git a/src/sys.zig b/src/sys.zig index 4bf87f89f5..2062abc9ed 100644 --- a/src/sys.zig +++ b/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 +/// 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); diff --git a/src/sys/PosixStat.zig b/src/sys/PosixStat.zig new file mode 100644 index 0000000000..c8975032af --- /dev/null +++ b/src/sys/PosixStat.zig @@ -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; diff --git a/src/sys_uv.zig b/src/sys_uv.zig index 4e01d9bb5f..48f548c1f0 100644 --- a/src/sys_uv.zig +++ b/src/sys_uv.zig @@ -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; diff --git a/test/js/bun/util/bun-file.test.ts b/test/js/bun/util/bun-file.test.ts index 48d543f58b..d4e8d5f1b3 100644 --- a/test/js/bun/util/bun-file.test.ts +++ b/test/js/bun/util/bun-file.test.ts @@ -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"); diff --git a/test/js/node/fs/fs-birthtime-linux.test.ts b/test/js/node/fs/fs-birthtime-linux.test.ts new file mode 100644 index 0000000000..e1cd536902 --- /dev/null +++ b/test/js/node/fs/fs-birthtime-linux.test.ts @@ -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); + }); +});