Skip to content

The object namespace, walked by name rather than printed - #157

Merged
glslang merged 10 commits into
mainfrom
feat/object-namespace
Sep 12, 2026
Merged

The object namespace, walked by name rather than printed#157
glslang merged 10 commits into
mainfrom
feat/object-namespace

Conversation

@glslang

@glslang glslang commented Sep 12, 2026

Copy link
Copy Markdown
Owner

\Device\MountPointManager is not an address, and nothing in dbgeng could turn it into one: a symbol names what the linker placed, while an object is created at run time and filed under a name in a tree the object manager keeps. The debugger's own answer is !object, an extension printing text. This walk answers in values.

It exists for a consumer: windbg-mcp's device_security needs a device's security descriptor, that pointer lives in the object's header, and the header is only reachable once the name has been resolved.

Surface

  • object_at(path) resolves a path to the object filed under it.
  • objects_in(path) lists a directory.
  • symbolic_link_target(link) reads where a link points.
  • object_layout() and object_globals() are public so a caller can see what the walk resolved.

Three decisions worth reviewing

Every bucket is walked rather than the one the name hashes to. The hash is the object manager's own, over a name folded with the kernel's upcase table. A wrong reimplementation of it does not fail — it looks in the wrong bucket and reports the object as absent, which is an answer a caller acts on. A directory holds tens of entries.

Not one literal offset, and two things that look like constants are derived. A pointer's width is the distance between a directory entry's two pointer fields, so a 32-bit kernel read from a 64-bit host is not decoded with the host's width. The bucket count is the array's span over that width, because 37 is this build's number and not the structure's.

A cap is an error rather than a short list. A directory reported shorter than it is answers "which symbolic links reach this device" wrongly, which is the one direction a security question must not fail in.

What the tests pin, each verified by breaking the rule it is for

rule what dropping it does
the descriptor's low three bits are flags reads a descriptor three bytes into its own header
TypeIndex is xored with the cookie and a byte of the header's address reads a type out of the wrong table slot, so a device reads as a directory
a link target is checked before it is followed decodes a name out of a callback context pointer
a chain is bounded walks a cycle until the process dies

LinkTarget shares storage with a callback pointer — Callback lands on Length and MaximumLength, CallbackContext lands on Buffer — and the fixture lays that union out as it really is rather than relying on a read that happens to fail.

Where it does not work

Not on a kernel minidump. Measured against the consumer's checked-in dump: nt!ObpRootDirectoryObject itself reads ????????, so the walk stops at its first read and names the address rather than reporting an empty namespace. A live kernel is this code's tier, and the unit tests run against synthetic bytes because there is no other way to test a walk over a running kernel.

Structure offsets were measured from the PDB on Windows 10 26100 x64 before anything was written.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Ayv1beKpAVkmDJDLfYqoyf

`\Device\MountPointManager` is not an address, and nothing here could turn it
into one: a symbol names what the linker placed, while an object is created at
run time and filed under a name in a tree the object manager keeps. The
debugger's answer is `!object`, an extension printing text. This is the same
walk answering in values, which is what a caller reading a device's security
descriptor needs -- the descriptor pointer is in the object's header, and the
header is only reachable once the name has been resolved.

`object_at` resolves a path, `objects_in` lists a directory, and
`symbolic_link_target` reads where a link points.

**Every bucket is walked rather than the one the name hashes to.** The hash is
the object manager's own, over a name folded with the kernel's upcase table, and
a wrong reimplementation of it does not fail -- it looks in the wrong bucket and
reports that the object is not there, which is an answer a caller acts on. A
directory holds tens of entries, so the saving is not worth that risk.

**Not one literal offset.** `_OBJECT_HEADER` has moved between Windows versions,
so every field comes from the target's own type information -- and two things
that look like constants are derived rather than assumed. A pointer's width is
the distance between a directory entry's two pointer fields, because a 32-bit
kernel read from this host would otherwise be decoded with the host's width and
every read after it would run off the end of something. The bucket count is the
array's span over that width, because 37 is this build's number and not the
structure's.

**A cap is an error rather than a short list**, which is the same rule the
security descriptor reader in the consumer follows: a directory reported shorter
than it is answers "which symbolic links reach this device" wrongly, and that is
the one direction a security question must not fail in. A chain that points at
itself is refused rather than walked.

Two subtleties the tests pin by mutation, one at a time:

- The header's `SecurityDescriptor` keeps three object-manager flags in its low
  bits. Taken as an address it reads a descriptor three bytes into its own
  header and reports a DACL that is not there.
- `TypeIndex` is exclusive-ored with a per-boot cookie **and with a byte of the
  header's own address**. Dropping either term reads a type out of the wrong
  table slot, which is how a device would read as a directory and be walked
  through.

`LinkTarget` shares its storage with a callback pointer: `Callback` lands exactly
on `Length` and `MaximumLength`, and `CallbackContext` lands on `Buffer`. So the
string is checked before it is followed, and the test lays that union out as it
really is rather than relying on a read that happens to fail.

This does not work on a kernel minidump. Measured against the consumer's
checked-in dump: `nt!ObpRootDirectoryObject` reads `????????`, so the walk stops
at its first read and names the address rather than reporting an empty
namespace. A live kernel is this code's tier.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ayv1beKpAVkmDJDLfYqoyf
@coderabbitai

coderabbitai Bot commented Sep 12, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 35504149-5802-4977-b1b3-b4be646dccf3


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 12, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-12T16:36:42.446816Z 75ce352 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f726fbb198

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/object.rs
root: self.symbol_offset("nt!ObpRootDirectoryObject")?,
info_mask_to_offset: self.symbol_offset("nt!ObpInfoMaskToOffset")?,
header_cookie: self.symbol_offset("nt!ObHeaderCookie").ok(),
type_index_table: self.symbol_offset("nt!ObTypeIndexTable").ok(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Resolve ObTypeIndexTable without the extra p

The Windows kernel global is nt!ObTypeIndexTable, as the Globals documentation above also names it, but this lookup requests nt!ObpTypeIndexTable. Because the error is discarded with .ok(), every DebugEngine-backed walk silently receives type_index_table: None; consequently KernelObject::type_name is never populated, callers cannot filter symbolic links as required by link_target, and the NotADirectory traversal guard is disabled.

Useful? React with 👍 / 👎.

Comment thread src/object.rs Outdated
.header_of(body)
.wrapping_add(u64::from(self.layout.header_security));
// The low three bits are the object manager's own, never part of the address.
let descriptor = self.pointer_at(at)? & !0b111;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Clear all four x64 fast-reference bits

On 64-bit kernels the security descriptor is stored as an _EX_FAST_REF, whose low four bits hold the reference count. When bit 3 is set, masking only 0b111 leaves the returned address eight bytes past the actual descriptor, so downstream security-descriptor and ACL decoding can produce incorrect security results; the mask needs to account for the target pointer width.

Useful? React with 👍 / 👎.

Comment thread src/object.rs Outdated
pub fn objects_in(&self, path: &str) -> Result<Vec<KernelObject>, ObjectError> {
let directory = match path.trim_end_matches('\\') {
"" => self.pointer_at(self.globals.root)?,
path => self.object_at(path)?.address,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject non-directories before enumerating them

When objects_in is called with a valid path to a leaf such as \Device\MountPointManager, object_at returns that device and this line passes its body directly to named_in. The walker then interprets the first 37 pointer-sized fields of a device object as directory buckets, potentially following arbitrary kernel pointers instead of returning NotADirectory; the final resolved object's type must be checked before enumeration.

Useful? React with 👍 / 👎.

Comment thread src/object.rs
Comment on lines +226 to +228
if object != 0 {
out.push(object);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bound directory links independently of non-null objects

The loop bound is based on out.len(), but that counter advances only when an entry's Object pointer is non-null. On a corrupt target where a null-object entry links to itself—or merely a chain containing more than the configured limit of null entries—the loop never reaches TooMany and can hang the debugger indefinitely, contrary to the stated corruption bound; count every traversed chain link rather than only emitted objects.

Useful? React with 👍 / 👎.

Comment thread src/object.rs Outdated
Comment on lines +250 to +251
if length == 0 || buffer == 0 {
return Ok(String::new());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject nonempty strings with null buffers

A UNICODE_STRING with a nonzero Length and null Buffer is malformed, but this branch returns a successful empty string. In particular, link_target validates the nonzero even length and maximum first, then reaches this path and reports Ok(""), turning corrupt target data into a valid symbolic-link target instead of an ObjectError.

Useful? React with 👍 / 👎.

…llows

Four review findings, each pinned by breaking the rule it is for.

**The security descriptor field is an `_EX_FAST_REF`.** The object manager keeps
a count of outstanding fast references in the bits an aligned address leaves
spare, and that count is **four** bits on a 64-bit kernel -- measured,
`nt!_EX_FAST_REF::RefCnt` is `Pos 0, 4 Bits` on 26100 x64. Clearing three of them
left the fourth standing on any object with eight or more live references, and
the address handed back was then eight bytes into the descriptor: what a caller
decodes from there is a DACL read out of the middle of a header, which is a wrong
answer about who may open a device rather than a failure to answer. The mask is
derived from the pointer width the layout already derives, since the count fills
what the descriptor's alignment leaves.

**A bound counts the links it follows, not the objects it emits.** An entry whose
`Object` is null contributes nothing to the list, so a bound counting the list
never reached one -- and one of those pointing at itself is a loop the walk never
leaves. That is not a test that fails, it is a debugger that stops answering, and
the mutation for it hangs rather than going red.

**Listing a leaf is refused rather than enumerated.** The guard against walking
*through* a device existed; the same guard at the *end* of a path did not, so
`objects_in` on a device read a driver's own fields as thirty-seven bucket
pointers and followed whatever they held.

**A length with no buffer is malformed, not empty.** Answered as an empty string
it becomes a symbolic link whose target is `""`, which a caller publishes as a
device reachable under no name at all.

One finding is **declined**, and the reason belongs here because the next round
will raise it against code that by then looks deliberate: the walk was said to
ask for `nt!ObpTypeIndexTable`, with the extra `p`, and to lose every type name
to the `.ok()` that follows. It asks for `nt!ObTypeIndexTable` and always has.
`x nt!ObpTypeIndexTable` resolves nothing on the checked-in dump; `x
nt!ObTypeIndexTable` resolves to `fffff805 cc3c6800`. Nothing changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ayv1beKpAVkmDJDLfYqoyf

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0399913985

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/object.rs
Comment on lines +475 to +479
if length == 0 || !length.is_multiple_of(2) || length > maximum {
return Err(ObjectError::Malformed {
reason: "the link target is not a string this can vouch for",
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Check the union discriminator before decoding link targets

When a callback-backed symbolic-link object has a callback address whose low words happen to form a nonzero even Length and a larger MaximumLength, this heuristic accepts the callback arm and unicode_at decodes bytes from CallbackContext as a link destination. Filtering by type_name cannot prevent this because callback-backed objects also have type SymbolicLink; inspect the target's callback/flags discriminator and reject or handle that arm before interpreting the union as a UNICODE_STRING.

Useful? React with 👍 / 👎.

The union `LinkTarget` sits in is not discriminated by the object's type: a
callback-backed symbolic link is a `SymbolicLink` like any other, so the doc
comment telling a caller to filter on `type_name` first was pointing at a gate
that is not there. `Callback` lands exactly on `Length` and `MaximumLength` and
`CallbackContext` lands on `Buffer`, so a code address whose low half happens to
be an even, non-zero, in-range length gets past checks that ask only whether
this is a string -- and what gets decoded is whatever the context pointer
addresses.

So the check is now what a **link target** is rather than what a string is: the
lengths have to be a string's, and then the thing they describe has to be an
object path, which begins at the root with a backslash. The test that was here
covered an address the lengths caught; the case that matters is the one they do
not, and it is now beside it.

**The discriminating flag is deliberately not read**, and this is where the
reason goes. `_OBJECT_SYMBOLIC_LINK::Flags` is what the object manager itself
branches on, and which bit that is could not be measured here: local kernel
debugging is not enabled on this host (`attach_kernel_local` answers
`0x80004001`), and a kernel minidump carries no namespace at all. A bit taken
from reading about it rather than from a target would be a rule nothing checked,
which is the shape of mistake this crate keeps finding in review. Refusing a
callback link cannot be wrong in the direction that matters. Reading the flag
would let one be answered instead, and that is worth doing from a live kernel
rather than from memory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ayv1beKpAVkmDJDLfYqoyf

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b7ed948722

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/object.rs Outdated
Comment on lines +441 to +444
if found
.type_name
.as_deref()
.is_some_and(|kind| kind != DIRECTORY)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require a known directory type before enumerating

When either optional type global is unavailable, or any read in type_of fails, type_name is None and this predicate permits enumeration. Calling objects_in on a leaf in that supported configuration then interprets the object's body as directory buckets, potentially returning a false empty result or following arbitrary pointers; require Some("Directory") before passing the address to named_in (and apply the same fail-closed rule to intermediate components).

Useful? React with 👍 / 👎.

Comment thread src/object.rs Outdated
Comment on lines +490 to +495
let target = self.unicode_at(at)?;
if !target.starts_with('\\') {
return Err(ObjectError::Malformed {
reason: "the link target is not an object path, so this is not a target",
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Check the callback discriminator before returning a target

Fresh evidence is the newly added starts_with('\\') gate: it still cannot distinguish the union arms when a callback address's low words satisfy the length checks and its CallbackContext points to UTF-16 data beginning with a backslash. In that case this returns arbitrary callback context as a symbolic-link destination, so the link's flags/discriminator must be checked rather than inferring the active arm from its contents.

Useful? React with 👍 / 👎.

Comment thread src/object.rs Outdated
Comment on lines +254 to +258
let length = u16::from_le_bytes(
bytes[self.layout.unicode_length as usize..][..2]
.try_into()
.unwrap_or_default(),
) as usize;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate Unicode length against MaximumLength

For object and type names, unicode_at reads Length but ignores the adjacent MaximumLength. A corrupt or torn UNICODE_STRING with Length > MaximumLength is therefore accepted and bytes beyond the declared buffer can become part of a name used for path matching, potentially resolving the wrong object instead of returning Malformed; validate the two fields before reading the buffer.

Useful? React with 👍 / 👎.

…not tell says no

Three findings, and the first sent me to read the kernel rather than argue with
the reviewer.

**The link union's discriminator is `Flags & 10h`**, measured out of
`nt!ObpParseSymbolicLinkEx` on 26100 x64: it loads `Flags`, tests `10h`, and on
that branch calls through the pointer at `+0x08` with the context at `+0x10`;
with the bit clear it reads `+0x08` as the `_UNICODE_STRING`. That is now read
first, so a callback-backed link is *answered* -- it has no target -- rather than
refused for failing a string check.

Worth recording why this was measured rather than looked up: the neighbouring
bits are all something else. `2h` asks whether the token is sandboxed, `8h`
masks an access mask, `1h` is a silo check. `2h` is the one a reader would try
first, and it is the wrong one.

The content checks stay, because they are cheap and they are what the kernel
itself does two blocks earlier -- `cmp word ptr [rax],5Ch`, the same backslash.
They are evidence now rather than the answer.

**A guard that cannot tell says no.** Both directory guards read
`type_name.is_some_and(...)`, which passes an object whose type could not be
read -- and the guard exists to stop a device's own fields being read as
thirty-seven bucket pointers, so passing on doubt is passing on exactly the case
it is for. Both now require `Some("Directory")`. That makes the type load-bearing,
so `ObHeaderCookie` and `ObTypeIndexTable` stop being optional globals: a walk
that cannot name a type cannot make the guarantee, and saying so at
`object_globals` is better than a guard that silently opens.

**A string is checked against its own maximum.** `Length` past `MaximumLength` is
a torn structure, and reading the length alone takes whatever follows into a name
the walk then matches paths against -- which resolves some *other* object rather
than failing to resolve this one.

Each of the three is pinned by breaking it, one at a time. The `maximum` mutation
also found a bug in my own harness: restoring a rule whose broken form is the
empty string prepends it to the file rather than putting it back, which
`cargo fmt` caught as a parse error at line 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ayv1beKpAVkmDJDLfYqoyf

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 419320414d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/object.rs Outdated
Comment on lines +617 to +618
header_cookie: self.symbol_offset("nt!ObHeaderCookie")?,
type_index_table: self.symbol_offset("nt!ObTypeIndexTable")?,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep type metadata optional for non-descending operations

On a target/PDB where ObHeaderCookie or ObTypeIndexTable cannot be resolved but the root and name metadata are available, these ? operators make object_globals fail before every with_namespace call. This unnecessarily disables safe operations that do not descend through an object—such as resolving a one-component path, listing \, or reading a symbolic-link target—and contradicts the documented type_name: None fallback; resolve these globals optionally and let the existing directory guards fail closed only when traversal actually requires a known type.

Useful? React with 👍 / 👎.

Comment thread src/object.rs Outdated
fn utf16(bytes: &[u8]) -> String {
let (pairs, _) = bytes.as_chunks::<2>();
let units: Vec<u16> = pairs.iter().copied().map(u16::from_le_bytes).collect();
String::from_utf16_lossy(&units)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Match object names on their original UTF-16 units

When the namespace contains a name with an unpaired UTF-16 surrogate, from_utf16_lossy replaces it with U+FFFD, and object_at subsequently uses that lossy String as the object's identity. A caller querying a literal U+FFFD can therefore resolve an object whose actual name differs, and multiple distinct non-scalar names can collapse to the same value so .find() returns whichever appears first; preserve the original code units for matching or reject such names rather than matching on the display representation.

Useful? React with 👍 / 👎.

… identity

Two findings, and the first is a round trip: the previous round made the type
globals required so the directory guards could fail closed, and this one asks for
them back because resolving a one-component path, listing the root and reading a
link target need no type at all. Both rounds were right about different things,
and neither position was the answer.

What was wrong underneath is that the guard had one refusal for two facts. An
object this walk could not type is not an object that *is* something else, and
reporting a directory as `NotADirectory` because the type table would not read is
a lie the caller would act on. So `Untyped` is its own variant, the globals go
back to optional, and the guards still fail closed -- saying which of the two it
is. A target that cannot name types now answers everything that needs no type and
refuses, accurately, the one thing that does.

**And a rendering is not an identity.** An unpaired surrogate is a legal object
name and an illegal `String`, so `from_utf16_lossy` turns it into a replacement
character: two different names render alike, and a caller asking for the
replacement character reaches an object whose name contains no such character.
For a walk whose answer feeds a security question, that is a device answering
under a name that is not its own -- and it is a name a driver chooses, not one it
is stuck with.

The name is still rendered, because an object nobody can list is worse than one
nobody can resolve by name, and `KernelObject::exact_name` says which it is.
`object_at` will not match an object it cannot reproduce exactly. A link target
that is not exactly its own bytes is refused outright, since there is no listing
to preserve there.

This round's harness refuses a mutation that maps a rule to the empty string,
which is how the last one corrupted the file while restoring.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ayv1beKpAVkmDJDLfYqoyf

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1a5d8719a6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/object.rs Outdated
reason: "the link target is not a string this can vouch for",
});
}
let (target, exact) = self.unicode_at(at)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use a separate size bound for symbolic-link targets

When a valid symbolic link has a target longer than 1,024 bytes, this call routes it through unicode_at, which applies MAX_NAME_BYTES and returns TooMany { what: "an object name", ... }. A link target is a separate counted UNICODE_STRING and can exceed the crate's deliberately conservative object-name limit, so symbolic_link_target rejects otherwise valid long targets; pass an operation-specific bound or decode link targets separately.

Useful? React with 👍 / 👎.

Comment thread src/object.rs
Comment on lines +681 to +682
pub fn symbolic_link_target(&self, link: u64) -> Result<String, ObjectError> {
self.with_namespace(|namespace| namespace.link_target(link))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid resolving namespace-only metadata for link targets

When the target exposes the symbolic-link and UNICODE_STRING layouts but omits or renames an unrelated namespace global such as ObpInfoMaskToOffset, symbolic_link_target still enters with_namespace, whose mandatory object_globals call fails before any link bytes are read. Namespace::link_target never uses the root, name-info table, type table, or directory layout, so this standalone operation should resolve only its own required fields instead of becoming unavailable whenever namespace-walking metadata is incomplete.

Useful? React with 👍 / 👎.

…ded as a path

Two findings, and the second is the third round on one seam: an operation
requiring more than it uses. Round three made the type globals required so the
directory guards could fail closed; round three's own finding took them back for
the operations that never descend; this one points out that reading a symbolic
link still needed the root pointer and the optional-header table, neither of
which it touches.

So the seam goes rather than the symptom. **Every global is optional at
resolution and required at use.** `Globals` records what the target has;
`needs` asks for one where it is read and names it in the refusal. Walking a
path wants the root, reading a name wants the offsets table, naming a type wants
the cookie and the table, and reading a link wants none of them -- which it now
demonstrably does, on a target that resolves nothing at all.

The layout is deliberately *not* treated this way, and the comment says so, so
the next round has the answer rather than the question: every type in it comes
out of one PDB, so a partial answer there is not something a real target
produces, while a global is a symbol that a build can rename.

**And a link target is bounded as a path rather than as a name.** It was sharing
`MAX_NAME_BYTES`, which is sized for one path *component* -- so an ordinary long
target was refused, with a message about object names, which is the tell that the
bound belonged to something else.

One note on method. The first mutation I wrote for the globals rule passed, and
it deserved to: it added a requirement inside a helper the operation under test
never calls, so it changed a path the test does not take. The rule is "reading a
link needs none of these", and the mutation that states it puts the requirement
in `link_target` itself. That one fails, which is what makes the test worth
having.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ayv1beKpAVkmDJDLfYqoyf

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9d0cf8baa2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/object.rs Outdated
reason: "the link target is not text, so it is not a path to follow",
});
}
if !target.starts_with('\\') {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Return valid non-rooted symbolic-link targets

When a valid symbolic-link destination is a DOS or relative string rather than an object-manager path—for example, a KnownDllPath value such as C:\Windows\System32—this gate returns Malformed even though the preceding flag check established that the union contains a UNICODE_STRING. The new Flags discriminator is fresh evidence that the content heuristic discussed in the earlier callback finding is no longer needed; symbolic_link_target should return the decoded target regardless of whether it begins with \.

Useful? React with 👍 / 👎.

Comment thread src/object.rs Outdated
4 => u64::from(u32::from_le_bytes(
bytes[..4].try_into().unwrap_or_default(),
)),
_ => u64::from_le_bytes(bytes[..8].try_into().unwrap_or_default()),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject unsupported pointer widths instead of panicking

Because both Namespace::new and every Layout field are public, a downstream memory walker can supply a width other than 4 or 8; for widths below 8, read returns only that many bytes and this fallback slices bytes[..8], panicking inside otherwise fallible object_at/objects_in calls. Validate the layout or return ObjectError::Malformed for unsupported widths rather than treating every non-4 value as 8.

AGENTS.md reference: AGENTS.md:L24-L24

Useful? React with 👍 / 👎.

…layout panics

Two findings, and the first retires something I defended one round ago.

**A target is returned whatever it looks like.** This required a leading
backslash, which refuses `\KnownDlls\KnownDllPath` -- a real link whose target is
the DOS path `C:\Windows\System32`. That rule was a stand-in for a discriminator,
and it stopped being worth anything the moment there was a flag to ask: once
`Flags` says the union holds a `UNICODE_STRING`, what is in it *is* the target and
this has no business second-guessing it. What remains is structural -- a whole
number of UTF-16 units, within its own maximum, addressing a buffer that reads.

The justification I gave for keeping it was also wrong, and the correction is in
the prose so the next reader has it: `ObpParseSymbolicLinkEx` does test a leading
backslash two blocks before the flag, and it is testing the **remaining name**
being parsed rather than the target. Reading one as the other is how a real link
came to be refused.

**And nothing panics on a layout this crate did not build.** `Layout` is public
and so is `Namespace::new`, so every offset in it is a caller's to fill in and
every one is an index into bytes this walk read. A pointer width of two had two
bytes read and eight taken; a string field placed past the end of its structure
is the same fault by another route. Both were a panic inside calls whose whole
contract is that they return an error. The width is now refused rather than
treated as eight, and field reads are bounds-checked against what was actually
read -- so the class goes rather than the instance the finding named.

Both mutations for that rule panic when backed out rather than going red, which
is the defect stating itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ayv1beKpAVkmDJDLfYqoyf

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: eed6699de9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/object.rs
});
}
let at = link.wrapping_add(u64::from(self.layout.link_target));
let size = (self.layout.unicode_buffer as usize) + self.layout.pointer;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate pointer width before sizing link targets

Fresh evidence beyond the earlier pointer-width finding is that link_target performs this unchecked addition before reaching pointer_at's width validation. With the public Layout set to an unsupported large width such as usize::MAX and readable link flags, debug builds panic here while optimized builds wrap the structure size and perform an undersized read; validate that the width is 4 or 8 before any size arithmetic so this fallible API returns Malformed instead.

AGENTS.md reference: AGENTS.md:L24-L24

Useful? React with 👍 / 👎.

Comment thread src/object.rs
fn entries_of(&self, directory: u64) -> Result<Vec<u64>, ObjectError> {
let mut out = Vec::new();
let mut followed = 0usize;
for bucket in 0..self.layout.buckets {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate the bucket count before iteration

Because callers can construct the public Layout, buckets == 0 makes objects_in("\\") return a false successful empty list, while an arbitrarily large count can keep scanning zero bucket pointers indefinitely because followed advances only for nonzero chain links. Validate the bucket count as nonzero and reasonably bounded before this loop rather than allowing malformed layouts to bypass the existing corruption bound.

Useful? React with 👍 / 👎.

Two more findings on the same seam, which is the signal to stop answering them
one at a time. The size arithmetic in `link_target` overflows before the width
check it would eventually reach, and a bucket count of zero turns a full
directory into an empty list while a huge one keeps the walk reading -- and
before those, a width of two had two bytes read and eight taken, and a field
offset past the end of its structure was taken from whatever followed. Four
defects, one cause: `Layout` is public, so its fields are a caller's to fill in,
and every read defended itself against the field it happened to use.

So the check moves to `Namespace::new`, which now returns a `Result`. Two kinds
of field, and the difference is worth stating rather than checking everything: a
**width** or a **count** is what the walk sizes reads and loops from, so a wrong
one panics or runs away and is refused. An **offset into a structure the walk
reads whole** has to be inside what it reads, or the read succeeds and the field
comes from past its end. An offset that is only added to an address is left
alone, deliberately -- a wrong one reads somewhere else, which comes back as
`Unreadable` naming the address, and there is nothing here to compare it against.

The walk below can now rely on the layout, and there is no next field to find.

Each of the four checks is pinned by disabling it alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ayv1beKpAVkmDJDLfYqoyf

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1bb493d522

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/object.rs
return bad("a directory's bucket count is not one a directory has");
}
// A `UNICODE_STRING` is read whole: its two lengths, then its buffer.
let unicode = (self.unicode_buffer as usize) + self.pointer;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bound UNICODE_STRING layout sizes before reading

Fresh evidence after the new Layout::check is that it still accepts enormous public offsets: for example, with pointer == 4, unicode_buffer == u32::MAX - 4, name_info_name == 0, and name_info_size == u32::MAX, unicode is nearly 4 GiB and both checks pass. link_target then requests that entire size, and DebugEngine::read_memory allocates a correspondingly large Vec, potentially aborting on OOM instead of returning ObjectError; on 32-bit builds, still larger offsets can also overflow this addition. Use checked arithmetic and reject structure sizes beyond a small realistic bound.

AGENTS.md reference: AGENTS.md:L24-L24

Useful? React with 👍 / 👎.

Comment thread src/object.rs Outdated
Comment on lines +588 to +589
let directory = match path.trim_end_matches('\\') {
"" => self.pointer_at(self.needs(self.globals.root, ROOT_SYMBOL)?)?,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject an empty directory path instead of treating it as root

When a caller passes objects_in(""), trim_end_matches produces the same empty value as it does for the canonical root path "\\", so this branch silently lists the root namespace. This bypasses the path validation used by object_at and turns missing or empty CLI/API input into a successful query for a different directory; only an actual root path should take this branch, while "" should return BadPath.

Useful? React with 👍 / 👎.

Two findings, the first of which is a gap in the check I wrote last round rather
than a new seam: it verified that the offsets are *ordered* the way a real
layout's are and never that they are *small*. A buffer offset of nearly four
gigabytes satisfies every ordering and then has the walk ask the target for a
read that size, which is an allocation failure rather than a refusal — and on a
32-bit host the sums that check the ordering overflow before they are compared.

So every offset is bounded before any of them is added to another. These describe
structures of tens of bytes: `_OBJECT_HEADER` is 0x30, a name header 0x20, a
`UNICODE_STRING` 0x10. Anything near a `u32`'s range is not one of them.

**And the root is `\` and nothing else is.** `objects_in` trimmed trailing
backslashes before deciding, which made the empty string the same value as the
root — so a caller whose argument went missing got a successful listing of a
directory it never asked about. `object_at` had always refused that, so one
question had two answers depending on which door it came through.

Both are pinned by disabling them alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ayv1beKpAVkmDJDLfYqoyf

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6660564b7e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/object.rs Outdated
Comment on lines +630 to +631
let directory = match path.trim_end_matches('\\') {
"" => self.pointer_at(self.needs(self.globals.root, ROOT_SYMBOL)?)?,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject extra separators instead of treating them as root

Fresh evidence beyond the earlier empty-path case is that objects_in("\\\\") still passes the prefix check and trim_end_matches('\\') reduces it to "", so it lists the root even though the documented root path is exactly "\\"; object_at rejects the same input as BadPath. Check for the exact root spelling before trimming trailing separators so malformed CLI/API input cannot resolve to a different directory.

Useful? React with 👍 / 👎.

…ropped

Third disagreement between two path parsers, so the second one goes.

`objects_in` had its own handling and it was lenient in a way `object_at` was
not: the empty string listed the root, then a path of nothing but separators
listed the root, because trimming trailing backslashes turns both into the same
value. Each was fixed where it was found. The parser they were supposed to share
was lenient too -- it *filtered* empty components rather than refusing them, so
`\Device\X` quietly became `\Device\X` and `\` quietly became the root.

So `components_of` refuses an empty component, answers the root as a path with no
components in it, and both callers ask it. `object_at` refuses that answer
because the root is a directory rather than an object in one; `objects_in` is the
call that means it. One trailing separator stays a caller's convenience, which is
the one piece of leniency that was deliberate.

The test now walks every spelling that used to reach the root by a different
route, and asserts the two calls agree on each.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ayv1beKpAVkmDJDLfYqoyf
@glslang
glslang merged commit da28ff1 into main Sep 12, 2026
7 checks passed
@glslang
glslang deleted the feat/object-namespace branch September 12, 2026 16:38
glslang added a commit that referenced this pull request Sep 13, 2026
…ete one

Two findings on #159, both correct.

**The halt was polled inside a chain and not before a bucket.** An empty bucket
never enters that loop, so a directory that is empty -- or merely has a long run
of empty buckets -- was unstoppable: `halting(&|| true)` still paid one target
read per bucket, up to the thousand a layout may declare, and then answered
`halted: false` having done all of it. It is polled before the read now.

The test gains a third construction for it, because the two it had could not
reach this one: an empty directory enters no chain and names no entry, so the
bucket poll is the only one that can fire there.

**And `skipped()` was being read as a completeness test it never was.** It
counts entries the walk *reached* and could not present, so a walk stopped
before reading any of them returns zero while missing an unknown number --
which a caller branching on `skipped() == 0` reads as the whole directory, the
one reading these counts exist to prevent. `is_complete()` is the question to
ask, and `skipped()` now says in its own docs that it is not that question.

Both mutation-verified.

Declined: renaming the new test to `test_*` per `AGENTS.md`. The convention is
real and this module does not follow it -- `src/object.rs` has twenty-four
descriptively named tests and no `test_`-prefixed one, merged that way across
#157 and #158. Renaming one would make it the only exception in the file, which
is worse than either consistency; renaming all twenty-four is a change of its
own and not this PR's subject.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ayv1beKpAVkmDJDLfYqoyf
glslang added a commit that referenced this pull request Sep 13, 2026
…ete one

Two findings on #159, both correct.

**The halt was polled inside a chain and not before a bucket.** An empty bucket
never enters that loop, so a directory that is empty -- or merely has a long run
of empty buckets -- was unstoppable: `halting(&|| true)` still paid one target
read per bucket, up to the thousand a layout may declare, and then answered
`halted: false` having done all of it. It is polled before the read now.

The test gains a third construction for it, because the two it had could not
reach this one: an empty directory enters no chain and names no entry, so the
bucket poll is the only one that can fire there.

**And `skipped()` was being read as a completeness test it never was.** It
counts entries the walk *reached* and could not present, so a walk stopped
before reading any of them returns zero while missing an unknown number --
which a caller branching on `skipped() == 0` reads as the whole directory, the
one reading these counts exist to prevent. `is_complete()` is the question to
ask, and `skipped()` now says in its own docs that it is not that question.

Both mutation-verified.

Declined: renaming the new test to `test_*` per `AGENTS.md`. The convention is
real and this module does not follow it -- `src/object.rs` has twenty-four
descriptively named tests and no `test_`-prefixed one, merged that way across
#157 and #158. Renaming one would make it the only exception in the file, which
is worse than either consistency; renaming all twenty-four is a change of its
own and not this PR's subject.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ayv1beKpAVkmDJDLfYqoyf
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant