Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 16 additions & 13 deletions doc/file/timestamps.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
# \File System Timestamps
# \Filesystem Timestamps

A file system entry (the name of a file or directory)
A filesystem entry (the name of a file or directory)
has several times (called timestamps) associated with it.

The Ruby methods that return these timestamps (each as a Time object)
are actually returning "whatever the OS says,"
and so their behaviors may vary among OS platforms.
If a platform does not support a particular timestamp,
the corresponding Ruby methods raise NotImplementedError.
A Ruby method that returns a filesystem timestamp (as a Time object)
is actually returning "whatever the filesystem says";
the returned times may vary among filesystems, even on the same machine.

These timestamps are:
These timestamps methods are:

| Name | Meaning | Changes |
|:--------------------------------:|----------------------------------------|-----------------------|
Expand All @@ -18,6 +16,9 @@ These timestamps are:
| [`atime`](#access-time) | Access time. | When read or written. |
| [`ctime`](#metadata-change-time) | Metadata-change time (or create time). | See below. |

A method raises an exception if the filesystem does not support
the corresponding timestamp.

## Birth \Time

The birth time for an entry is the time the entry was created.
Expand All @@ -42,7 +43,7 @@ On Windows, each of these methods also returns the birth time:

The modification time for an entry is the time the entry was last modified.
The modification time is updated when the entry is written,
though some file systems may delay the update.
though some filesystems may delay the update.

Each of these methods returns the modification time for an entry as a Time object:

Expand All @@ -60,9 +61,11 @@ The modification time (along with the access time) may also be updated explicitl

## Access \Time

The access time for an entry is the time the entry last read.
The access time is updated when the entry is read,
though some file systems may delay the update.
The access time for an entry is the time of the most recent read of or write to
the content of the entry, as reported by the underlying filesystem.

Depending on a filesystem's settings, reading an entry may cause the access time
to be updated immediately, later, or never.

Each of these methods returns the access time for an entry as a Time object:

Expand All @@ -83,7 +86,7 @@ The access time (along with the modification time) may also be updated explicitl
The metadata-change time for an entry is the time the entry last read.
The metadata-change time is updated when the entry's metadata is changed;
changing access mode or permissions may update the metadata-change time,
though some file systems may delay the update.
though some filesystems may delay the update.

On non-Windows systems,
each of these methods returns the metadata-change time for an entry:
Expand Down
2 changes: 2 additions & 0 deletions ext/-test-/gc/register/extconf.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# frozen_string_literal: false
create_makefile("-test-/gc/register")
62 changes: 62 additions & 0 deletions ext/-test-/gc/register/register.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
#include "ruby.h"

/*
* Regression test for a heap-use-after-free in rb_gc_unregister_address().
*
* This mirrors the pattern used by some C extensions (e.g. Nokogiri's XPath
* argument marshalling): a buffer is ruby_xcalloc()'d, the address of each of
* its slots is registered with rb_gc_register_address(), and later each slot
* is unregistered before the buffer is ruby_xfree()'d.
* https://github.com/sparklemotion/nokogiri/blob/fea733159ad8c0328c591125bb1fb30681859a0d/ext/nokogiri/xml_xpath_context.c#L231-L255
*
* A buggy rb_gc_unregister_address() wrote *through* the address being removed
* (corrupting the sibling slots) and failed to drop the entry from its internal
* list, leaving a dangling pointer into the freed buffer that the next GC would
* dereference.
*
* Returns true if unregistering one slot leaves the other registered slots
* untouched. The trailing ruby_xfree()+gc additionally reproduces the literal
* use-after-free for ASAN/Valgrind builds.
*/
static VALUE
gc_unregister_address_keeps_siblings(VALUE self)
{
const int n = 4;
VALUE *buf = ruby_xcalloc((size_t)n, sizeof(VALUE));
VALUE saved = rb_ary_new_capa(n);
VALUE result = Qtrue;

for (int i = 0; i < n; i++) {
buf[i] = rb_sprintf("registered address %d", i);
rb_gc_register_address(&buf[i]);
rb_ary_push(saved, buf[i]); /* keep the objects reachable */
}

/* Unregistering a middle slot must only update the internal list; it must
* not write through &buf[1] and corrupt the sibling slots. */
rb_gc_unregister_address(&buf[1]);
for (int i = 0; i < n; i++) {
if (i == 1) continue;
if (buf[i] != rb_ary_entry(saved, i)) result = Qfalse;
}

/* Clean up the way an extension would. With the bug, a dangling registered
* address into this freed buffer triggers a use-after-free in the GC. */
rb_gc_unregister_address(&buf[0]);
rb_gc_unregister_address(&buf[2]);
rb_gc_unregister_address(&buf[3]);
ruby_xfree(buf);
rb_gc();

RB_GC_GUARD(saved);
return result;
}

void
Init_register(void)
{
VALUE mBug = rb_define_module("Bug");
VALUE mGC = rb_define_module_under(mBug, "GC");
rb_define_singleton_method(mGC, "unregister_address_keeps_siblings?",
gc_unregister_address_keeps_siblings, 0);
}
2 changes: 1 addition & 1 deletion gc.c
Original file line number Diff line number Diff line change
Expand Up @@ -3762,7 +3762,7 @@ rb_gc_unregister_address(VALUE *addr)
for (index = 0; index < vm->global_object_list_size; index++) {
if (addr == vm->global_object_list[index]) {
MEMMOVE(
vm->global_object_list[index],
&vm->global_object_list[index],
&vm->global_object_list[index + 1],
VALUE *,
vm->global_object_list_size - index - 1
Expand Down
195 changes: 81 additions & 114 deletions pathname_builtin.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1072,48 +1072,39 @@ def write(...) File.write(@path, ...) end
# with ASCII-8BIT encoding.
def binwrite(...) File.binwrite(@path, ...) end

# :markup: markdown
#
# call-seq:
# atime -> new_time
#
# Returns a new Time object containing the time of the most recent
# access (read or write) to the entry represented by `self`;
# see {File System Timestamps}[rdoc-ref:file/timestamps.md]:
#
# # Work in a temporary directory.
# require 'tmpdir'
# Dir.mktmpdir do |tmpdirpath|
# # A subdirectory therein, and its Pathname.
# dirpath = File.join(tmpdirpath, 'subdir')
# Dir.mkdir(dirpath)
# dir_pn = Pathname(dirpath)
# puts "Create directory; establishes atime for directory."
# puts " Directory atime: #{dir_pn.atime}"
# sleep(1)
#
# # A file in the subdirectory, and its Pathname.
# filepath = File.join(dirpath, 't.txt')
# puts "Create file; establishes atime for file, updates atime for directory."
# File.write(filepath, 'foo')
# file_pn = Pathname(filepath)
# puts " File atime: #{file_pn.atime}"
# puts " Directory atime: #{dir_pn.atime}"
# sleep(1)
# puts "Write file; updates atimes for file and directory."
# File.write(filepath, 'bar')
# puts " File atime: #{file_pn.atime}"
# puts " Directory atime: #{dir_pn.atime}"
# end
# Returns a Time object containing the access time
# of the entry represented by `self`, as reported by the filesystem;
# see {File System Access Time}[rdoc-ref:file/timestamps.md@Access+Time]:
#
# Output:
#
# Create directory; establishes atime for directory.
# Directory atime: 2026-05-14 14:36:43 +0100
# Create file; establishes atime for file, updates atime for directory.
# File atime: 2026-05-14 14:36:44 +0100
# Directory atime: 2026-05-14 14:36:44 +0100
# Write file; updates atimes for file and directory.
# File atime: 2026-05-14 14:36:45 +0100
# Directory atime: 2026-05-14 14:36:45 +0100
# ```ruby
# # Pathname for a (non-existent) directory.
# dir_pn = Pathname('doc/foo') # => #<Pathname:doc/foo>
# # Create directory; establishes atime for directory.
# dir_pn.mkdir
# dir_pn.atime # => 2026-06-17 10:10:20.801115774 -0500
# # Pathname for a (non-existent) file in the directory.
# file_pn = dir_pn.join('t.tmp') # => #<Pathname:doc/foo/t.tmp>
# # Create file; establishes atime for file, updates atime for directory.
# file_pn.write('foo')
# file_pn.atime # => 2026-06-17 10:11:40.987171568 -0500
# dir_pn.atime # => 2026-06-17 10:11:40.96617277 -0500
# # Write file; updates atime for file,but not directory.
# file_pn.write('bar')
# file_pn.atime # => 2026-06-17 10:13:22.062904563 -0500
# dir_pn.atime # => 2026-06-17 10:11:40.96617277 -0500
# # Read file; may update atime for file, but not directory.
# file_pn.read
# file_pn.atime # => 2026-06-17 10:13:22.062904563 -0500
# dir_pn.atime # => 2026-06-17 10:11:40.96617277 -0500
# # Clean up.
# file_pn.delete
# dir_pn.rmdir
# ```
#
def atime() File.atime(@path) end

Expand Down Expand Up @@ -1238,45 +1229,30 @@ def mtime() File.mtime(@path) end
# chmod(mode) -> 1
#
# Changes the mode (i.e., permissions) of the entry represented by `self`;
# see {File Permissions}[rdoc-ref:File@File+Permissions];
# returns `1`:
# see {File Permissions}[rdoc-ref:File@File+Permissions]:
#
# ```ruby
# # A helper method to make an integer mode display as octal.
# def pretty(mode); '0' + (mode & 0777).to_s(8); end
#
# # Work in a temporary directory.
# Pathname.mktmpdir do |tmpdirpath|
# # A subdirectory therein, and its Pathname.
# dirpath = File.join(tmpdirpath, 'subdir')
# dir_pn = Pathname(dirpath)
# dir_pn.mkdir
# # The directory mode.
# puts "Original directory mode: #{pretty(dir_pn.stat.mode)}"
# # Change the directory mode.
# dir_pn.chmod(0777)
# puts "New directory mode: #{pretty(dir_pn.stat.mode)}"
#
# # A file in the subdirectory, and its Pathname.
# filepath = File.join(dirpath, 't.txt')
# file_pn = Pathname(filepath)
# # Create the file.
# file_pn.write('foo')
# # The file mode.
# puts "Original file mode: #{pretty(file_pn.stat.mode)}"
# # Change the file modes.
# file_pn.chmod(0777)
# puts "New file mode: #{pretty(file_pn.stat.mode)}"
# end
# ```
#
# Output:
# # Pathname for a (non-existent) directory.
# dir_pn = Pathname('doc/foo') # => #<Pathname:doc/foo>
# # Create the directory and fetch its mode.
# dir_pn.mkdir
# dir_pn.stat.mode.to_s(8) # => "40775"
# # Change the directory mode and fetch the new mode.
# dir_pn.chmod(0777)
# dir_pn.stat.mode.to_s(8) # => "40777"
#
# # Pathname for a (non-existent) file in the directory.
# file_pn = dir_pn.join('t.tmp') # => #<Pathname:doc/foo/t.tmp>
# # Create the file and fetch its mode.
# file_pn.write('foo')
# file_pn.stat.mode.to_s(8) # => "100664"
# # Change the file mode and fetch its new mode.
# file_pn.chmod(0777)
# file_pn.stat.mode.to_s(8) # => "100777"
#
# ```text
# Original directory mode: 0775
# New directory mode: 0777
# Original file mode: 0664
# New file mode: 0777
# # Clean up.
# file_pn.delete
# dir_pn.rmdir
# ```
#
def chmod(mode) File.chmod(mode, @path) end
Expand Down Expand Up @@ -1313,47 +1289,38 @@ def lchmod(mode) File.lchmod(mode, @path) end
# Changes the owner and group of an entry (directory or file):
#
# ```ruby
# # Work in a temporary directory.
# Pathname.mktmpdir do |tmpdirpath|
# # A subdirectory therein, and its Pathname.
# dirpath = File.join(tmpdirpath, 'subdir')
# dir_pn = Pathname(dirpath)
# dir_pn.mkdir
# dir_stat = File.stat(dirpath)
# puts "Original directory owner: #{dir_stat.uid}"
# puts "Original directory group: #{dir_stat.gid}"
# dir_pn.chown(1000, 1000)
# dir_stat = File.stat(dirpath)
# puts "New directory owner: #{dir_stat.uid}"
# puts "New directory group: #{dir_stat.gid}"
#
# # A file in the subdirectory, and its Pathname.
# filepath = File.join(dirpath, 't.txt')
# file_pn = Pathname(filepath)
# # Create the file.
# file_pn.write('foo')
# file_stat = File.stat(filepath)
# puts "Original file owner: #{file_stat.uid}"
# puts "Original file group: #{file_stat.gid}"
# file_pn = Pathname(dirpath)
# file_pn.chown(1000, 1000)
# file_stat = File.stat(dirpath)
# puts "New file owner: #{file_stat.uid}"
# puts "New file group: #{file_stat.gid}"
# end
# ```
#
# Output:
# # Super user; all privileges.
# Process.uid # => 0
# Process.gid # => 0
#
# ```text
# Original directory owner: 0
# Original directory group: 0
# New directory owner: 1000
# New directory group: 1000
# Original file owner: 0
# Original file group: 0
# New file owner: 1000
# New file group: 1000
# # Pathname for a (non-existent) directory.
# dir_pn = Pathname('doc/foo') # => #<Pathname:doc/foo>
# # Create the directory; fetch original owner and group.
# dir_pn.mkdir
# dir_stat = dir_pn.stat
# dir_stat.uid # => 0
# dir_stat.gid # => 0
# # Change owner; fetch current owner and group.
# dir_pn.chown(1000, 1000)
# dir_stat = dir_pn.stat
# dir_stat.uid # => 1000
# dir_stat.gid # => 1000
#
# Pathname for a (non-existent) file in the directory.
# file_pn = dir_pn.join('t.tmp') # => #<Pathname:doc/foo/t.tmp>
# # Create the directory; fetch original owner and group.
# file_pn.write('foo')
# file_stat = file_pn.stat
# file_stat.uid # => 0
# file_stat.gid # => 0
# # Change owner; fetch current owner and group.
# file_pn.chown(1000, 1000)
# file_stat = file_pn.stat
# file_stat.uid # => 1000
# file_stat.gid # => 1000
# # Clean up.
# file_pn.delete
# dir_pn.rmdir
# ```
#
# Notes:
Expand Down
12 changes: 12 additions & 0 deletions test/-ext-/gc/test_register.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# frozen_string_literal: false
require 'test/unit'
require '-test-/gc/register'

class Test_GCRegisterAddress < Test::Unit::TestCase
# Regression test for a heap-use-after-free in rb_gc_unregister_address():
# unregistering one registered address must not corrupt the sibling slots or
# leave a dangling pointer for the next GC to mark.
def test_unregister_address_keeps_other_registered_addresses
assert_equal(true, Bug::GC.unregister_address_keeps_siblings?)
end
end
11 changes: 8 additions & 3 deletions tool/lib/test/unit.rb
Original file line number Diff line number Diff line change
Expand Up @@ -460,10 +460,15 @@ def after_worker_down(worker, e=nil, c=false)
real_file = worker.real_file and warn "running file: #{real_file}"
@need_quit = true
warn ""
warn "A test worker crashed. It might be an interpreter bug or"
warn "a bug in test/unit/parallel.rb. Try again without the -j"
warn "option."
warn "A test worker crashed. It might be a bug in the"
warn "Ruby runtime or a bug in test/unit/parallel.rb."
warn "You may want to try again without the -j option."
warn ""
if ENV["RUBY_CRASH_REPORT"]
warn "hint: RUBY_CRASH_REPORT is set and may have produced additional information."
warn " Crash logs might be collapsed under a different fold on CI." if "true" == ENV["GITHUB_ACTIONS"]
warn ""
end
if File.exist?('core')
require 'fileutils'
require 'time'
Expand Down
Loading