diff --git a/cli/standalone/virtual_fs.rs b/cli/standalone/virtual_fs.rs index 0ae00accbf..26bb0db75f 100644 --- a/cli/standalone/virtual_fs.rs +++ b/cli/standalone/virtual_fs.rs @@ -350,6 +350,7 @@ impl<'a> VfsEntryRef<'a> { atime: None, birthtime: None, mtime: None, + ctime: None, blksize: 0, size: 0, dev: 0, @@ -372,6 +373,7 @@ impl<'a> VfsEntryRef<'a> { atime: None, birthtime: None, mtime: None, + ctime: None, blksize: 0, size: file.len, dev: 0, @@ -394,6 +396,7 @@ impl<'a> VfsEntryRef<'a> { atime: None, birthtime: None, mtime: None, + ctime: None, blksize: 0, size: 0, dev: 0, diff --git a/cli/tsc/dts/lib.deno.ns.d.ts b/cli/tsc/dts/lib.deno.ns.d.ts index 6e0e84b687..8179e4223c 100644 --- a/cli/tsc/dts/lib.deno.ns.d.ts +++ b/cli/tsc/dts/lib.deno.ns.d.ts @@ -2971,6 +2971,10 @@ declare namespace Deno { * field from `stat` on Mac/BSD and `ftCreationTime` on Windows. This may * not be available on all platforms. */ birthtime: Date | null; + /** The last change time of the file. This corresponds to the `ctime` + * field from `stat` on Mac/BSD and `ChangeTime` on Windows. This may + * not be available on all platforms. */ + ctime: Date | null; /** ID of the device containing the file. */ dev: number; /** Inode number. @@ -2979,8 +2983,7 @@ declare namespace Deno { ino: number | null; /** The underlying raw `st_mode` bits that contain the standard Unix * permissions for this file/directory. - * - * _Linux/Mac OS only._ */ + */ mode: number | null; /** Number of hard links pointing to this file. * diff --git a/ext/fs/30_fs.js b/ext/fs/30_fs.js index c8e19ac758..40513e7e02 100644 --- a/ext/fs/30_fs.js +++ b/ext/fs/30_fs.js @@ -346,9 +346,10 @@ const { 0: statStruct, 1: statBuf } = createByteStruct({ mtime: "date", atime: "date", birthtime: "date", + ctime: "date", dev: "u64", ino: "?u64", - mode: "?u64", + mode: "u64", nlink: "?u64", uid: "?u64", gid: "?u64", @@ -377,9 +378,10 @@ function parseFileInfo(response) { birthtime: response.birthtimeSet === true ? new Date(response.birthtime) : null, + ctime: response.ctimeSet === true ? new Date(response.ctime) : null, dev: response.dev, + mode: response.mode, ino: unix ? response.ino : null, - mode: unix ? response.mode : null, nlink: unix ? response.nlink : null, uid: unix ? response.uid : null, gid: unix ? response.gid : null, diff --git a/ext/fs/in_memory_fs.rs b/ext/fs/in_memory_fs.rs index e29b9d50c6..34b77836d9 100644 --- a/ext/fs/in_memory_fs.rs +++ b/ext/fs/in_memory_fs.rs @@ -229,6 +229,7 @@ impl FileSystem for InMemoryFs { mtime: None, atime: None, birthtime: None, + ctime: None, dev: 0, ino: 0, mode: 0, @@ -251,6 +252,7 @@ impl FileSystem for InMemoryFs { mtime: None, atime: None, birthtime: None, + ctime: None, dev: 0, ino: 0, mode: 0, diff --git a/ext/fs/ops.rs b/ext/fs/ops.rs index 9b76b49e61..3d0d96ce66 100644 --- a/ext/fs/ops.rs +++ b/ext/fs/ops.rs @@ -1795,6 +1795,8 @@ create_struct_writer! { atime: u64, birthtime_set: bool, birthtime: u64, + ctime_set: bool, + ctime: u64, // Following are only valid under Unix. dev: u64, ino: u64, @@ -1826,6 +1828,8 @@ impl From for SerializableStat { atime: stat.atime.unwrap_or(0), birthtime_set: stat.birthtime.is_some(), birthtime: stat.birthtime.unwrap_or(0), + ctime_set: stat.ctime.is_some(), + ctime: stat.ctime.unwrap_or(0), dev: stat.dev, ino: stat.ino, diff --git a/ext/fs/std_fs.rs b/ext/fs/std_fs.rs index 1a83c97c53..73439d9bab 100644 --- a/ext/fs/std_fs.rs +++ b/ext/fs/std_fs.rs @@ -821,24 +821,46 @@ fn stat_extra( Ok(info.dwVolumeSerialNumber as u64) } + const WINDOWS_TICK: i64 = 10_000; // 100-nanosecond intervals in a millisecond + const SEC_TO_UNIX_EPOCH: i64 = 11_644_473_600; // Seconds between Windows epoch and Unix epoch + + fn windows_time_to_unix_time_msec(windows_time: &i64) -> i64 { + let milliseconds_since_windows_epoch = windows_time / WINDOWS_TICK; + milliseconds_since_windows_epoch - SEC_TO_UNIX_EPOCH * 1000 + } + use windows_sys::Wdk::Storage::FileSystem::FILE_ALL_INFORMATION; + use windows_sys::Win32::Foundation::NTSTATUS; unsafe fn query_file_information( handle: winapi::shared::ntdef::HANDLE, - ) -> std::io::Result { + ) -> Result { use windows_sys::Wdk::Storage::FileSystem::NtQueryInformationFile; + use windows_sys::Win32::Foundation::RtlNtStatusToDosError; + use windows_sys::Win32::Foundation::ERROR_MORE_DATA; + use windows_sys::Win32::System::IO::IO_STATUS_BLOCK; let mut info = std::mem::MaybeUninit::::zeroed(); + let mut io_status_block = + std::mem::MaybeUninit::::zeroed(); let status = NtQueryInformationFile( handle as _, - std::ptr::null_mut(), + io_status_block.as_mut_ptr(), info.as_mut_ptr() as *mut _, std::mem::size_of::() as _, 18, /* FileAllInformation */ ); if status < 0 { - return Err(std::io::Error::last_os_error()); + let converted_status = RtlNtStatusToDosError(status); + + // If error more data is returned, then it means that the buffer is too small to get full filename information + // to have that we should retry. However, since we only use BasicInformation and StandardInformation, it is fine to ignore it + // since struct is populated with other data anyway. + // https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntifs/nf-ntifs-ntqueryinformationfile#remarksdd + if converted_status != ERROR_MORE_DATA { + return Err(converted_status as NTSTATUS); + } } Ok(info.assume_init()) @@ -862,10 +884,13 @@ fn stat_extra( } let result = get_dev(file_handle); - CloseHandle(file_handle); fsstat.dev = result?; if let Ok(file_info) = query_file_information(file_handle) { + fsstat.ctime = Some(windows_time_to_unix_time_msec( + &file_info.BasicInformation.ChangeTime, + ) as u64); + if file_info.BasicInformation.FileAttributes & winapi::um::winnt::FILE_ATTRIBUTE_REPARSE_POINT != 0 @@ -898,6 +923,7 @@ fn stat_extra( } } + CloseHandle(file_handle); Ok(()) } } diff --git a/ext/io/fs.rs b/ext/io/fs.rs index 8854265209..7ef02315ba 100644 --- a/ext/io/fs.rs +++ b/ext/io/fs.rs @@ -94,6 +94,7 @@ pub struct FsStat { pub mtime: Option, pub atime: Option, pub birthtime: Option, + pub ctime: Option, pub dev: u64, pub ino: u64, @@ -153,6 +154,16 @@ impl FsStat { } } + #[inline(always)] + fn get_ctime(ctime_or_0: i64) -> Option { + if ctime_or_0 > 0 { + // ctime return seconds since epoch, but we need milliseconds + return Some(ctime_or_0 as u64 * 1000); + } + + None + } + Self { is_file: metadata.is_file(), is_directory: metadata.is_dir(), @@ -162,6 +173,7 @@ impl FsStat { mtime: to_msec(metadata.modified()), atime: to_msec(metadata.accessed()), birthtime: to_msec(metadata.created()), + ctime: get_ctime(unix_or_zero!(ctime)), dev: unix_or_zero!(dev), ino: unix_or_zero!(ino), diff --git a/ext/node/polyfills/_fs/_fs_stat.ts b/ext/node/polyfills/_fs/_fs_stat.ts index d00c81ffb6..507cb05eaf 100644 --- a/ext/node/polyfills/_fs/_fs_stat.ts +++ b/ext/node/polyfills/_fs/_fs_stat.ts @@ -290,8 +290,8 @@ export function convertFileInfoToStats(origin: Deno.FileInfo): Stats { isFIFO: () => false, isCharacterDevice: () => false, isSocket: () => false, - ctime: origin.mtime, - ctimeMs: origin.mtime?.getTime() || null, + ctime: origin.ctime, + ctimeMs: origin.ctime?.getTime() || null, }); return stats; @@ -336,9 +336,9 @@ export function convertFileInfoToBigIntStats( isFIFO: () => false, isCharacterDevice: () => false, isSocket: () => false, - ctime: origin.mtime, - ctimeMs: origin.mtime ? BigInt(origin.mtime.getTime()) : null, - ctimeNs: origin.mtime ? BigInt(origin.mtime.getTime()) * 1000000n : null, + ctime: origin.ctime, + ctimeMs: origin.ctime ? BigInt(origin.ctime.getTime()) : null, + ctimeNs: origin.ctime ? BigInt(origin.ctime.getTime()) * 1000000n : null, }); return stats; } diff --git a/tests/unit/stat_test.ts b/tests/unit/stat_test.ts index 59831a069f..0609035b41 100644 --- a/tests/unit/stat_test.ts +++ b/tests/unit/stat_test.ts @@ -31,6 +31,13 @@ Deno.test( assert( tempInfo.birthtime === null || now - tempInfo.birthtime.valueOf() < 1000, ); + assert(tempInfo.ctime !== null && now - tempInfo.ctime.valueOf() < 1000); + const mode = tempInfo.mode! & 0o777; + if (Deno.build.os === "windows") { + assertEquals(mode, 0o666); + } else { + assertEquals(mode, 0o600); + } const readmeInfoByUrl = Deno.statSync(pathToAbsoluteFileUrl("README.md")); assert(readmeInfoByUrl.isFile); @@ -65,6 +72,10 @@ Deno.test( tempInfoByUrl.birthtime === null || now - tempInfoByUrl.birthtime.valueOf() < 1000, ); + assert( + tempInfoByUrl.ctime !== null && + now - tempInfoByUrl.ctime.valueOf() < 1000, + ); Deno.removeSync(tempFile, { recursive: true }); Deno.removeSync(tempFileForUrl, { recursive: true }); @@ -171,6 +182,7 @@ Deno.test( assert( tempInfo.birthtime === null || now - tempInfo.birthtime.valueOf() < 1000, ); + assert(tempInfo.ctime !== null && now - tempInfo.ctime.valueOf() < 1000); const tempFileForUrl = await Deno.makeTempFile(); const tempInfoByUrl = await Deno.stat( @@ -191,7 +203,10 @@ Deno.test( tempInfoByUrl.birthtime === null || now - tempInfoByUrl.birthtime.valueOf() < 1000, ); - + assert( + tempInfoByUrl.ctime !== null && + now - tempInfoByUrl.ctime.valueOf() < 1000, + ); Deno.removeSync(tempFile, { recursive: true }); Deno.removeSync(tempFileForUrl, { recursive: true }); }, @@ -271,7 +286,6 @@ Deno.test( const s = Deno.statSync(filename); assert(s.dev !== 0); assert(s.ino === null); - assert(s.mode === null); assert(s.nlink === null); assert(s.uid === null); assert(s.gid === null); diff --git a/tests/unit_node/_fs/_fs_stat_test.ts b/tests/unit_node/_fs/_fs_stat_test.ts index 02c620e2dc..e42aa34a9a 100644 --- a/tests/unit_node/_fs/_fs_stat_test.ts +++ b/tests/unit_node/_fs/_fs_stat_test.ts @@ -18,9 +18,11 @@ export function assertStats(actual: Stats, expected: Deno.FileInfo) { assertEquals(actual.atime?.getTime(), expected.atime?.getTime()); assertEquals(actual.mtime?.getTime(), expected.mtime?.getTime()); assertEquals(actual.birthtime?.getTime(), expected.birthtime?.getTime()); + assertEquals(actual.ctime?.getTime(), expected.ctime?.getTime()); assertEquals(actual.atimeMs ?? undefined, expected.atime?.getTime()); assertEquals(actual.mtimeMs ?? undefined, expected.mtime?.getTime()); assertEquals(actual.birthtimeMs ?? undefined, expected.birthtime?.getTime()); + assertEquals(actual.ctimeMs ?? undefined, expected.ctime?.getTime()); assertEquals(actual.isFile(), expected.isFile); assertEquals(actual.isDirectory(), expected.isDirectory); assertEquals(actual.isSymbolicLink(), expected.isSymlink); @@ -49,6 +51,7 @@ export function assertStatsBigInt( assertEquals(actual.atime?.getTime(), expected.atime?.getTime()); assertEquals(actual.mtime?.getTime(), expected.mtime?.getTime()); assertEquals(actual.birthtime?.getTime(), expected.birthtime?.getTime()); + assertEquals(actual.ctime?.getTime(), expected.ctime?.getTime()); assertEquals( actual.atimeMs === null ? undefined : Number(actual.atimeMs), expected.atime?.getTime(), @@ -61,6 +64,10 @@ export function assertStatsBigInt( actual.birthtimeMs === null ? undefined : Number(actual.birthtimeMs), expected.birthtime?.getTime(), ); + assertEquals( + actual.ctimeMs === null ? undefined : Number(actual.ctimeMs), + expected.ctime?.getTime(), + ); assertEquals(actual.atimeNs === null, actual.atime === null); assertEquals(actual.mtimeNs === null, actual.mtime === null); assertEquals(actual.birthtimeNs === null, actual.birthtime === null);