Skip to content

refactor: unify workspace root resolution; fix cloud project-config layering + secret-name encoding#167

Closed
markovejnovic wants to merge 3 commits into
cli-41-secretsfrom
cli-41-refactor
Closed

refactor: unify workspace root resolution; fix cloud project-config layering + secret-name encoding#167
markovejnovic wants to merge 3 commits into
cli-41-secretsfrom
cli-41-refactor

Conversation

@markovejnovic

Copy link
Copy Markdown
Contributor

Stacked on #152 (cli-41-secrets) — it builds on hm-core, which only exists there, so --base cli-41-secrets keeps the diff to the refactor alone. Contains no secrets-interface scaffolding.

Three independent commits, each builds standalone (verified in detached worktrees).


291b80e — unify root resolution behind Workspace::resolve

Three verbs each hand-rolled the same block, none consulting a Workspace:

let root = match args.dir { Some(d) => d, None => current_dir()? };

Workspace::resolve now owns the single rule: --dir names the root verbatim, no walk-up (the backend runs discovery against cloned repo roots — walking up out of a clone could bind to an unrelated .hm/ above it); no --dir walks up from cwd.

Fixes a real divergence in hm run. render_pipeline took a &RunContext holding a fully-validated Workspace, ignored it (the param was literally _ctx), and recomputed the root from --dir/cwd. That PathBuf became req.repo_root and propagated into the scheduler, archive builder, secret resolver, and cloud backend — so with --dir, config came from one tree and code from another.

Side effect: hm run --dir X now works from a cwd outside any workspace. It previously died with "no harmont workspace found" because from_cli walked up from cwd and ignored --dir.

⚠️ Behaviour change worth your eyes: hm render / hm pipelines now walk up when given no --dir, where they were cwd-exact and failed in subdirectories. Discovery is unaffected — the backend always passes --dir.

hm pipelines keeps its empty-envelope contract: only NotFound/InvalidPath/InvalidWorkspace map to it, so a malformed .hm/config.toml still fails loudly instead of masquerading as a repo with no pipelines.

14c2b2f — cloud verbs read the project .hm/config.toml layer

They called Config::load(None) (user + env only), so [cloud] org in a project config was silently ignored — while the error message named the very file being ignored:

no organization — set [cloud] org = "…" in ~/.config/hm/config.toml (or .hm/config.toml)

hm run read it correctly via ctx.workspace.config(), so the two halves of the CLI disagreed about the active org in the same directory.

hm cloud org switch deliberately keeps Config::load(None) — it's a load-mutate-save on the user file, and merging the project layer would persist project-scoped values into ~/.config/hm/config.toml. Commented in place.

Pre-existing wrinkle left alone: Config::load merges env, so HM_API_URL=x hm cloud org switch y still writes api_url=x to the user config.

5520183 — stop escaping RFC 3986 unreserved chars in secret names

0d2256d swapped SEGMENT for NON_ALPHANUMERIC, which also escapes - . _ ~. hm cloud secret rm DEPLOY_TOKEN was requesting /secrets/DEPLOY%5FTOKEN — a different resource than the user named. DEPLOY-TOKEN_v2.0~rc was mangled four ways.

Keeps that commit's win (30 hand-listed bytes gone) by deriving the set rather than enumerating it:

const SEGMENT: &AsciiSet = &NON_ALPHANUMERIC
    .remove(b'-').remove(b'.').remove(b'_').remove(b'~');

This also unbreaks the suite, red since 0d2256d. That commit renamed unreserved_name_is_not_encodedspecial_chars_are_encoded and flipped its expectation to the escaped output, dropping the comment "RFC 3986 unreserved chars (incl. - _ . ~) must pass through" — the bug was written down as the spec. It left item_path_appends_encoded_name asserting the opposite, so the two tests contradicted each other and the suite could not pass either way.


Verification

206 passed, 0 failed — green for the first time since 0d2256d. Beyond tests, the behaviour was driven against the built binary: project-config layering in/outside a project and from nested subdirs; --dir exactness (a --dir at a non-workspace subdir returns the empty envelope rather than escaping upward); relative --dir; walk-up from subdirectories; and the hm run --dir divergence.

Three verbs each hand-rolled the same root resolution:

    let root = match args.dir { Some(d) => d, None => current_dir()? };

in cli/render.rs, cli/pipelines.rs, and commands/run/mod.rs. None of them
consulted a Workspace. Replace all three with Workspace::resolve, which owns
the single rule:

- `--dir` names the project root verbatim, with no walk-up. The backend runs
  discovery against cloned repo roots, and walking up out of a clone could
  bind to an unrelated `.hm/` above it. A relative `--dir` is resolved against
  the cwd, since a workspace root must be absolute.
- No `--dir`: walk up from the cwd, so a verb works from any subdirectory.

Fixes a divergence in `hm run`. render_pipeline took a &RunContext holding a
fully-validated Workspace, ignored it (the parameter was named `_ctx`), and
recomputed the root from `--dir`/cwd. That PathBuf became req.repo_root and
propagated into the scheduler, the archive builder, the secret resolver and
the cloud backend -- so with `--dir`, config came from one tree and code from
another. RunContext::from_cli now takes the verb's `--dir` and resolves once;
render_pipeline uses ctx.workspace.path().

As a side effect `hm run --dir X` now works from a cwd outside any workspace.
It previously failed with "no harmont workspace found" because from_cli walked
up from the cwd and ignored `--dir` entirely.

Behaviour change: `hm render` and `hm pipelines` now walk up when given no
`--dir`, where they were previously cwd-exact and failed in subdirectories.
Discovery is unaffected -- the backend always passes `--dir`.

`hm pipelines` keeps its contract that a repo with no `.hm/` yields the empty
envelope rather than an error. Only NotFound/InvalidPath/InvalidWorkspace map
to it; a malformed `.hm/config.toml` still fails loudly instead of
masquerading as a repo with no pipelines.

CurrentDir and NotFound move onto WorkspaceLoadError, next to the other ways
resolving a root can fail. context::Error is dropped: it had become a
single-variant passthrough.
The cloud verbs called Config::load(None) -- user and env layers only -- so
`[cloud] org` set in a project's .hm/config.toml was silently ignored. The
error message users hit told them to set it in exactly the file being
ignored:

    no organization — set `[cloud] org = "…"` in ~/.config/hm/config.toml
    (or .hm/config.toml), or run `hm cloud org switch <slug>`

`hm run` read the project layer correctly via ctx.workspace.config(), so the
two halves of the CLI disagreed about the active org in the same directory.

settings::config() now walks up from the cwd and passes the project config
path to Config::load, which already takes an Option<&Path> for precisely
this. Outside a project there is simply no project layer and the cloud verbs
still work from anywhere.

`hm cloud org switch` deliberately keeps Config::load(None): it is a
load-mutate-save on the user file, and merging the project layer in first
would persist project-scoped values into ~/.config/hm/config.toml. Commented
in place so it isn't "fixed" later.

Note a pre-existing wrinkle left alone here: Config::load also merges the env
layer, so `HM_API_URL=x hm cloud org switch y` still writes api_url=x into the
user config.
0d2256d replaced the hand-listed SEGMENT AsciiSet with NON_ALPHANUMERIC,
which also escapes `-`, `.`, `_` and `~`. Those are RFC 3986 unreserved and
must pass through: `hm cloud secret rm DEPLOY_TOKEN` was requesting
/secrets/DEPLOY%5FTOKEN, a different resource than the user named. A name
like DEPLOY-TOKEN_v2.0~rc was mangled four ways.

Keep that commit's win -- 30 hand-listed bytes are gone -- by deriving the
set instead of enumerating it:

    const SEGMENT: &AsciiSet = &NON_ALPHANUMERIC
        .remove(b'-').remove(b'.').remove(b'_').remove(b'~');

This is RFC 3986's unreserved set by construction rather than by a list that
has to be audited. `/` and space still escape, so a name cannot break out of
its path segment.

This also unbreaks the test suite, which has been red since 0d2256d. That
commit renamed unreserved_name_is_not_encoded to special_chars_are_encoded
and flipped its expectation to the escaped output, dropping the comment
"RFC 3986 unreserved chars (incl. `-` `_` `.` `~`) must pass through" -- the
bug was written down as the spec. It left item_path_appends_encoded_name
asserting the opposite, so the two tests contradicted each other and the
suite could not pass either way.

Restore the original test under its original name, looping over the
characters rather than asserting one string -- a single case is what let `_`
be noticed while `-.~` broke silently. Add reserved_chars_are_encoded to pin
the other half of the contract.
@markovejnovic

Copy link
Copy Markdown
Contributor Author

Superseded by #168, which rebuilds this on main so the refactor can land independently of the secrets feature.

#167 was based on cli-41-secrets, so it could only merge after #152. #168 targets main directly and carries the same work minus two pieces that genuinely can't go to main — the raw_org_ctxSecretString change and the percent-encoding fix — because verbs/secret.rs doesn't exist there. Those stay with #152.

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