Skip to content

chore(sessions): Add Primites for Paths and Patches - #248

Merged
evanspearman merged 2 commits into
mainfrom
evan/patches
May 28, 2026
Merged

chore(sessions): Add Primites for Paths and Patches#248
evanspearman merged 2 commits into
mainfrom
evan/patches

Conversation

@evanspearman

@evanspearman evanspearman commented May 28, 2026

Copy link
Copy Markdown
Member

More details can be found in the modules respective doc strings, but at a high level:

Part 1: Paths

If you're just using PathBuf everywhere for paths, you run into a few problems:
- PathBuf can represent paths with non-utf8 characters. We currently only support utf8 paths so converting between paths and rust strings is a pain
- PathBuf contains no context as to what filesystem a path is for, so there is potential for bugs if we mix them up
- PathBuf you can do weird things like joining an absolute path onto the end of another path which will silently succeed even though it is a logical error

The solution to this was:
- Adopt camino's Utf8PathBuf as our core path primitive instead of std's PathBuf
- Create a concept of Realm for paths which are encoded into wrapper types using Phantom markers using Rust's type system to prevent paths from different realms from mixing without explicit translation
- Separate type wrappers for absolute and relative paths so we can use Rust's type system to prevent logically illegal operations (as well as an enum for cases when a path can be either without loosing type safety)

Part 2: Patches

First pass at a data model for file system patches. This includes the FileSet primitive which is re-used both in patches themselves and patch policies (allow, deny, ignore lists). Patch primitive for specifying files to be patched, as well as "promoted" forms with provenance as to where the patch came from for logs, error messages, etc.

Summary by CodeRabbit

Release Notes

  • New Features

    • Added patch management system to include host files in sessions with policy controls (allow/deny/ignore)
    • Introduced realm-typed filesystem paths with construction-time validation for enhanced safety
    • Added environment variable management with inheritance, defaults, and explicit value specification
    • Enhanced external hook script paths to enforce relative-path-only validation
  • Chores

    • Added camino and globset dependencies

Review Change Stack

@coderabbitai

coderabbitai Bot commented May 28, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Introduces a realm-tagged path type system with validated AbsPath and RelPath variants per realm, a Var enum for variable representation, integrates both into HookScript and Loadout, and implements a complete patch domain model with file-pattern matching, destination validation, and policy evaluation.

Changes

Realm-tagged paths, variables, and patch system

Layer / File(s) Summary
Realm-tagged path type system
crates/sessions/src/paths.rs
Introduces Realm trait with concrete marker types (Host, Sandbox, Daemon, ConfigRelative), AbsPath<R> for absolute UTF-8 paths, RelPath<R> for relative paths, and EitherPath<R> for either variant in a realm. All support validated construction, realm-safe operations (join, resolve_against, parent), accessors (file_name, extension, components), and serde serialization with absoluteness validation. Includes Translator trait for cross-realm path translation and comprehensive internal test suite validating construction errors, operations, TOML round-trips, and trait adapters.
Variable representation
crates/sessions/src/vars.rs
Introduces Var enum with Inherit, InheritWithDefault { default: String }, and Specified { value: String } variants, derived with Clone, Debug, and Serde support for configuration use.
Integration: lifecycle hooks and loadout
crates/sessions/src/lifecyclehook.rs, crates/sessions/src/loadout.rs
Updates HookScript::External to store ConfigRelPath (relative to config directory) instead of PathBuf, rejecting absolute paths during deserialization. Updates Loadout struct to replace env_vars: HashMap<String, String> with vars: BTreeMap<String, Var> and adjusts serde attributes. Tests validate new shapes and absolute-path rejection behavior.
Patch domain model
crates/sessions/src/patches.rs
Introduces PatchOrigin (User/Project/Package), FileSet (validated glob patterns with optional base), PatchDest (validated sandbox-relative destination), wire types PatchDecl/PatchDecls with promote to attach origins and produce domain Patch/Patches, and PatchPolicy (allow/deny/ignore file sets). Includes validation errors (Error enum with glob parsing, empty paths, destination traversal failures), serde support for wire-to-domain conversion, and comprehensive unit tests covering parsing, validation, origin attachment, and policy defaults.
Dependencies and module exports
Cargo.toml, crates/sessions/Cargo.toml, crates/sessions/src/lib.rs
Adds camino (v1 with serde1 feature) and globset dependencies to workspace and sessions crate. Exports new patches, paths, and vars modules from sessions crate root.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • gominimal/minimal#230: Extends and refactors the previously added sessions crate primitives—specifically updating lifecyclehook.rs to use ConfigRelPath instead of PathBuf for external hook scripts and changing loadout.rs from env_vars to the new vars/Var model.

Suggested reviewers

  • twitchyliquid64
  • norrietaylor

Poem

🐰 Paths with realms, so neatly tagged,
Patches prowl where globs have dragged,
Vars inherit, default, specify,
Config-relative scripts no longer fly,
Sessions grow their sturdy spine!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Title check ❓ Inconclusive The title uses "Primites" which appears to be a typo for "Primitives"; the PR actually adds path and patch types, not primitives. Clarify the title: consider "Add Primitives for Paths and Patches" or a more specific description of what is being introduced (e.g., realm-tagged path types, patch domain models).
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

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

@coderabbitai coderabbitai 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.

Actionable comments posted: 4

🧹 Nitpick comments (1)
crates/sessions/src/loadout.rs (1)

12-12: ⚡ Quick win

Remove the commented-out serde attribute.

Line 12 is dead commented-out code now that the real field attribute already lives on vars.

As per coding guidelines, "Do not include dead code, commented-out code, or ownerless TODOs."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/sessions/src/loadout.rs` at line 12, Remove the dead commented-out
serde attribute on the field by deleting the line "// #[serde(default,
skip_serializing_if = "BTreeMap::is_empty")]" so only the actual attribute on
vars remains; locate the commented line near the vars field in loadout.rs and
remove it to comply with the coding guideline against commented-out code.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/sessions/src/loadout.rs`:
- Around line 8-9: The Loadout struct's vars field currently uses
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")] but does not
accept legacy "env_vars", so legacy configs are dropped; update the vars field
on the Loadout struct to include a serde alias/rename (e.g., add alias =
"env_vars" or rename = "env_vars" in the attribute) so deserialization accepts
both names, and also remove the commented-out serde attribute near the
lifecycle_hooks field to eliminate dead/commented-out code; reference the vars
field and lifecycle_hooks in crates/sessions/src/loadout.rs when making these
edits.

In `@crates/sessions/src/patches.rs`:
- Around line 371-381: PatchDecl currently allows an empty FileSet which lets
configs like `source = []` silently become no-ops; update PatchDecl::new to
reject FileSet::empty() by validating the `source` parameter (e.g., if
source.is_empty() { panic!("PatchDecl source may not be empty"); } or change the
constructor to return Result and Err with a clear message). Modify the `pub fn
new(source: FileSet, dest: PatchDest)` implementation in the PatchDecl impl to
perform this check and emit a clear error, referencing PatchDecl::new,
PatchDecl, and FileSet::is_empty()/FileSet::empty() so callers/deserializers get
a consistent failure.

In `@crates/sessions/src/paths.rs`:
- Around line 333-335: Update stale intra-doc links in the comments to point to
the actual constructor names: replace references to RelPath::new and
AbsPath::new with RelPath::try_new and AbsPath::try_new respectively in
crates/sessions/src/paths.rs (search for the comment that currently mentions
`RelPath::new` and the places mentioning `AbsPath::new`/`RelPath::new` around
the module docs), ensuring all markdown-style rustdoc links use the `try_new`
names so rustdoc resolves correctly.

In `@crates/sessions/src/vars.rs`:
- Around line 1-5: The enum Var is currently using Serde's default
externally-tagged representation so a bare TOML string won't deserialize into
Specified { value: String }; mark the enum as untagged and make Specified a
newtype so plain strings map to it: add #[serde(untagged)] to Var, change
Specified { value: String } to Specified(String), and update any callsites that
accessed Specified.value to use the new inner String (e.g., match
Var::Specified(s) or unwrap the inner string); leave Inherit and
InheritWithDefault { default: String } as-is.

---

Nitpick comments:
In `@crates/sessions/src/loadout.rs`:
- Line 12: Remove the dead commented-out serde attribute on the field by
deleting the line "// #[serde(default, skip_serializing_if =
"BTreeMap::is_empty")]" so only the actual attribute on vars remains; locate the
commented line near the vars field in loadout.rs and remove it to comply with
the coding guideline against commented-out code.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f8a74723-f259-4d4b-a697-3637145e9890

📥 Commits

Reviewing files that changed from the base of the PR and between 1561ee2 and 61ae1a7.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (8)
  • Cargo.toml
  • crates/sessions/Cargo.toml
  • crates/sessions/src/lib.rs
  • crates/sessions/src/lifecyclehook.rs
  • crates/sessions/src/loadout.rs
  • crates/sessions/src/patches.rs
  • crates/sessions/src/paths.rs
  • crates/sessions/src/vars.rs

Comment on lines +8 to +9
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
vars: BTreeMap<String, Var>,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

In Serde's default struct deserialization, are unknown fields ignored unless deny_unknown_fields is set?

💡 Result:

Yes. In Serde’s default struct deserialization behavior (for self-describing formats like JSON), unknown fields are ignored unless you opt into stricter checking with #[serde(deny_unknown_fields)]. Serdes’s container attribute docs state that #[serde(deny_unknown_fields)] “Always error during deserialization when encountering unknown fields” and that “When this attribute is not present, by default unknown fields are ignored for self-describing formats like JSON.” [1][2]

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n "struct Loadout|env_vars|vars:|skip_serializing_if|serde.*default|deny_unknown_fields" crates/sessions/src/loadout.rs
nl -ba crates/sessions/src/loadout.rs | sed -n '1,80p'

Repository: gominimal/minimal

Length of output: 408


🏁 Script executed:

#!/bin/bash
set -euo pipefail
sed -n '1,120p' crates/sessions/src/loadout.rs
sed -n '120,240p' crates/sessions/src/loadout.rs

Repository: gominimal/minimal

Length of output: 513


🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n "deny_unknown_fields|rename\s*=|alias\s*=|env_vars|vars:" crates/sessions/src/loadout.rs

Repository: gominimal/minimal

Length of output: 120


🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n "env_vars" -S .
rg -n "Loadout" crates/sessions/src -S
rg -n "serde\(([^)]*rename|[^)]*alias|[^)]*deny_unknown_fields)" -S crates/sessions/src

Repository: gominimal/minimal

Length of output: 6582


Prevent legacy env_vars from being ignored during Serde deserialization

  • Loadout’s vars field (#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]) has no alias = "env_vars"/rename and there’s no deny_unknown_fields, so legacy configs providing env_vars will be treated as unknown and effectively dropped (deserializing vars as the default empty map).
  • Remove the commented-out #[serde(...)] attribute in crates/sessions/src/loadout.rs (near the lifecycle_hooks field) to avoid dead/commented-out code.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/sessions/src/loadout.rs` around lines 8 - 9, The Loadout struct's vars
field currently uses #[serde(default, skip_serializing_if =
"BTreeMap::is_empty")] but does not accept legacy "env_vars", so legacy configs
are dropped; update the vars field on the Loadout struct to include a serde
alias/rename (e.g., add alias = "env_vars" or rename = "env_vars" in the
attribute) so deserialization accepts both names, and also remove the
commented-out serde attribute near the lifecycle_hooks field to eliminate
dead/commented-out code; reference the vars field and lifecycle_hooks in
crates/sessions/src/loadout.rs when making these edits.

Comment on lines +371 to +381
#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct PatchDecl {
source: FileSet,
dest: PatchDest,
}

impl PatchDecl {
/// Construct a declaration without origin.
#[must_use]
pub fn new(source: FileSet, dest: PatchDest) -> Self {
Self { source, dest }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Reject empty patch sources.

FileSet::empty() makes sense for policy defaults, but PatchDecl currently accepts the same value for source. That allows patches = [{ dest = "/x", source = [] }] to load as a silent no-op instead of surfacing a config error.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/sessions/src/patches.rs` around lines 371 - 381, PatchDecl currently
allows an empty FileSet which lets configs like `source = []` silently become
no-ops; update PatchDecl::new to reject FileSet::empty() by validating the
`source` parameter (e.g., if source.is_empty() { panic!("PatchDecl source may
not be empty"); } or change the constructor to return Result and Err with a
clear message). Modify the `pub fn new(source: FileSet, dest: PatchDest)`
implementation in the PatchDecl impl to perform this check and emit a clear
error, referencing PatchDecl::new, PatchDecl, and
FileSet::is_empty()/FileSet::empty() so callers/deserializers get a consistent
failure.

Comment on lines +333 to +335
/// Invariant: `inner.is_absolute()` is always false. Construction goes
/// through [`RelPath::new`], which validates the input.
#[must_use]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n 'RelPath::new|AbsPath::new|fn\s+try_new\b|impl<.*>\s+(AbsPath|RelPath)' crates/sessions/src/paths.rs

Repository: gominimal/minimal

Length of output: 487


Fix stale intra-doc links for AbsPath::new / RelPath::new. crates/sessions/src/paths.rs defines AbsPath::try_new and RelPath::try_new, but the docs still reference [RelPath::new] (line 334) and [AbsPath::new] / [RelPath::new] (around line 599-600), which leaves invalid rustdoc links. Update those to the try_new constructors.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/sessions/src/paths.rs` around lines 333 - 335, Update stale intra-doc
links in the comments to point to the actual constructor names: replace
references to RelPath::new and AbsPath::new with RelPath::try_new and
AbsPath::try_new respectively in crates/sessions/src/paths.rs (search for the
comment that currently mentions `RelPath::new` and the places mentioning
`AbsPath::new`/`RelPath::new` around the module docs), ensuring all
markdown-style rustdoc links use the `try_new` names so rustdoc resolves
correctly.

Comment on lines +1 to +5
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub enum Var {
Inherit,
InheritWithDefault { default: String },
Specified { value: String },

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

In Serde's default derived enum representation for TOML, how does an enum with one unit variant and struct variants serialize/deserialize, and would a bare string like "bar" deserialize into a variant shaped like Specified { value: String }?

💡 Result:

In Serde's default (externally tagged) enum representation, an enum is serialized as a single-key map (or table in TOML) where the key is the variant name and the value is the variant's content [1]. For an enum with a unit variant and a struct variant, serialization and deserialization work as follows: 1. Unit Variant: Serialized as a simple string representing the variant name [2][3]. For example, MyEnum::Unit becomes my_enum = "Unit". 2. Struct Variant: Serialized as a map (table) where the variant name is the key, and the value is another table containing the struct fields [4][2]. For example, MyEnum::Specified { value: String } becomes my_enum = { Specified = { value = "bar" } }. Regarding your second question, a bare string like "bar" will not automatically deserialize into a variant shaped like Specified { value: String } under the default externally tagged representation [4][2]. The deserializer expects the structure to match the externally tagged format (i.e., a map containing the variant name) [1]. A bare string "bar" is expected to match a unit variant named "bar" [2][3]. To achieve deserialization of a bare string into a struct variant, you would need to use alternative Serde attributes such as #[serde(untagged)] on the enum (which changes the entire representation to be content-based rather than tag-based) [1][5] or use a custom deserialization function with #[serde(deserialize_with = "...")] to handle the conversion from a string to that specific struct variant [6]. Note that using #[serde(untagged)] can introduce ambiguity if multiple variants could potentially match the input [7].

Citations:


Add an explicit Serde representation for Var.

Var’s bare #[derive(serde::Serialize, serde::Deserialize)] uses Serde’s default externally tagged enum representation, where struct variants like Specified { value: String } are not deserialized from a bare TOML string. In TOML, Specified { value: "bar" } is expected to come in as a table/map shape (e.g., Specified = { value = "bar" }), while a plain string like "bar" won’t map to Specified { value: String }—it only fits unit-variant inputs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/sessions/src/vars.rs` around lines 1 - 5, The enum Var is currently
using Serde's default externally-tagged representation so a bare TOML string
won't deserialize into Specified { value: String }; mark the enum as untagged
and make Specified a newtype so plain strings map to it: add #[serde(untagged)]
to Var, change Specified { value: String } to Specified(String), and update any
callsites that accessed Specified.value to use the new inner String (e.g., match
Var::Specified(s) or unwrap the inner string); leave Inherit and
InheritWithDefault { default: String } as-is.

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