Skip to content

Stop the token file inheriting permissions, and three FFI fixes - #2

Merged
beingsuz merged 3 commits into
mainfrom
claude/project-thread-lagm2g
Sep 26, 2026
Merged

beingsuz merged 3 commits into
mainfrom
claude/project-thread-lagm2g

Conversation

@claude

@claude claude Bot commented Sep 20, 2026 •

Copy link
Copy Markdown

Requested by beingsuz · project thread

Before: saving a refresh token wrote tokens.tmp and renamed it into place. On Windows that file was opened with CREATE_ALWAYS, which applies the access-control list we build only when the call actually creates the file; on unix OpenOptions::mode(0o600) applies at creation and not when an existing file is opened. So a temporary left behind by a crashed run, or one another local account pre-created, kept its own permissions, took the secret, and handed those permissions to the real store through the rename. A descriptor read back with a conditional access-control entry in it lost that entry entirely, so a file shared with everyone read as private. libraries() asked only for HOME, which Windows does not set, and gave up before looking in Program Files, so it came back empty on every Windows machine. Across the C surface, two threads polling one job could each take a message and each write the held slot, silently dropping one; tapline_set_total_concurrency answered OK and did nothing whenever anything had already built the pool; and asking tapline_last_error for a length with a null buffer reported the buffer too small when there was no error at all.

After: the temporary is removed before the token is written and the file is created exclusively, so a race is an error rather than a readable secret — with a test that fails on the old code and passes on the new. Conditional entries are parsed by counting brackets, and an entry that never closes counts as an entry rather than as nothing. libraries() looks the same way discover() does and keeps going without a home directory. tapline_job_next holds one lock for the whole call, the concurrency latch is set where the budget is actually frozen and checked before the value is stored, and an empty error message reads as success.

tapline-steamworks also claimed in a safety comment that the process was still single-threaded when it set SteamAppId. A library linked into someone else's host cannot know that. The call stays — there is no safe way to set an environment variable in Rust and no other way to tell the Steam client which app it is — but it is skipped when the host has already set the variable, and Steam::connect now documents the requirement instead of asserting it. Its error buffer was [i8; 1024], which does not compile where c_char is unsigned, and was read with CStr::from_ptr, which would scan past the end of an unterminated buffer; both are fixed in safe Rust.

How: clear_temporary runs before both create_private arms, create_with_dacl uses CREATE_NEW and says why in its doc comment, and the unix arm uses create_new(true). aces() walks the DACL body tracking bracket depth. roots() takes Option<&Path> so a machine with no home still gets the install roots. In the FFI crate the held-message lock is taken once at the top of tapline_job_next, STARTED is latched inside the pool's get_or_init, and out-pointer writes go through write_unaligned.

On the unsafe audit: nothing here could be moved to safe Rust without changing the C ABI or dropping the Windows access-control list, which was a deliberate choice. tapline-ffi is a C surface, so its entry points must take raw pointers; tapline-auth/src/windows_acl.rs stays the single cfg(windows) exception it was. What changed is that the safety comments now say what is actually checked, and two of them no longer assert things that were not true.

Verified: cargo clippy --workspace --all-targets and the same with --target x86_64-pc-windows-gnu are both clean, cargo test --workspace passes, cargo fmt --all --check is clean. Not verified: nothing here has been run on a real Windows machine — the Windows paths are typechecked by the cross-compile and nothing more.

The refresh token is written to `tokens.tmp` and renamed into place. Both
platforms apply the permissions we ask for when the file is created and
only then: `CREATE_ALWAYS` over an existing file on Windows keeps that
file's access-control list and ignores the descriptor passed to it, and
`OpenOptions::mode` on unix applies to a create, not to an open. A
temporary left behind by a crashed run -- or one another local account
pre-created, which the working-directory config fallback makes reachable
-- therefore lent its own permissions to the secret, and the rename
carried them onto the store.

Remove the temporary first, then create exclusively, so a race fails with
an error instead of a readable token.

Also in this crate:

- Conditional access-control entries were dropped when a descriptor was
  read back, because the parser cut each entry at its first `)` and a
  conditional entry carries brackets of its own. A file shared with
  everyone through one read as private. Count brackets instead, and treat
  an entry that never closes as an entry rather than as nothing.
- `libraries()` looked only at `HOME`, which Windows does not set, and
  gave up before trying the Program Files roots. It now asks the same way
  `discover()` does and keeps going without a home directory.
- Bound the `TOKEN_USER` read by the buffer Windows filled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3w2xdTR76kYV6RA52J4Ag
Three things across the C surface:

- `tapline_job_next` took the held message, read the queue and put the
  message back in three separate critical sections, so two threads
  polling one job could both find the slot empty, both take a message,
  and both write the slot -- dropping one. The header promises every
  function is safe to call from any thread, so hold one lock for the
  whole call.
- `tapline_set_total_concurrency` latched on a flag set when a job is
  spawned, but the budget is frozen the first time the pool is built,
  which asking for the current concurrency already does. Calling
  `tapline_total_concurrency()` first therefore made every later
  `set_total_concurrency` answer OK and change nothing. Latch where the
  budget is actually frozen, and check before storing so a rejected call
  leaves the value alone.
- Asking `tapline_last_error` for the length with a null buffer answered
  TAPLINE_BUFFER_TOO_SMALL when there was no error to report.

Out-pointer writes now go through `write_unaligned`, which costs nothing
and removes an alignment precondition C has no way to promise us, and
the safety comments say what is actually checked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3w2xdTR76kYV6RA52J4Ag
`Steam::connect` set `SteamAppId` with a comment claiming the process was
still single-threaded. A library linked into someone else's game-server
host cannot know that, and on glibc `setenv` can reallocate `environ`
under a concurrent `getenv`. There is no safe way to set an environment
variable in Rust and no way to hand the app id to the Steam client except
through one, so the call stays -- but it is now skipped whenever the host
has already set the variable, and `connect` documents the requirement
rather than asserting something it cannot know.

Two more in the same function: the error buffer was `[i8; 1024]`, which
does not compile where `c_char` is unsigned, and it was read with
`CStr::from_ptr`, which would scan past the end if Steam ever filled it
without a terminator. Both are now bounded and target-independent, in
safe Rust.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3w2xdTR76kYV6RA52J4Ag
@coderabbitai

coderabbitai Bot commented Sep 20, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Bot user detected.

To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 9143362d-5759-426c-8c9e-4ecd0e2da69d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

@claude
claude Bot requested a review from beingsuz September 20, 2026 16:35
@beingsuz
beingsuz marked this pull request as ready for review September 26, 2026 01:25
@beingsuz
beingsuz merged commit 3e638bf into main Sep 26, 2026
8 checks passed
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.

2 participants