Skip to content

feat: unify cache paths via platform-aware dirs::cache_dir() (#229)#230

Merged
inureyes merged 3 commits into
mainfrom
feat/229-unify-cache-paths
May 24, 2026
Merged

feat: unify cache paths via platform-aware dirs::cache_dir() (#229)#230
inureyes merged 3 commits into
mainfrom
feat/229-unify-cache-paths

Conversation

@inureyes

Copy link
Copy Markdown
Member

Summary

Route every all-smi cache writer — record output, energy WAL, and the users-tab CSV export — through a single platform-aware helper built on dirs::cache_dir(). Retires the three hard-coded ~/.cache/all-smi/... literals and the hand-rolled cache_dir_for_all_smi() reimplementation, so the cache layer is finally as platform-correct as the config layer (config_dir() already was). Builds on #227 (Scope A of #223).

What changed

  • New helper. crate::common::paths::cache_dir() mirrors config_dir(): dirs::cache_dir().map(|d| d.join(APP_DIR_NAME)). Returns Option<PathBuf> so callers can downgrade gracefully on bare CI shells / containers without $HOME.
  • record.output_dir and energy.wal_path are now Option<String>. None (the new compiled default) means "use the platform helper"; Some(s) is an explicit operator override resolved through expand_tilde. The CLI flag surface and the TOML key names are unchanged; ALL_SMI_ENERGY_WAL_PATH / ALL_SMI_RECORD_OUTPUT_DIR continue to work.
  • Consumers updated. record::RecorderOptions::from_args_with_settings now layers -o flag → config output_dir (expand_tilde) → paths::cache_dir().join("records") → CWD fallback. api::server::run_api_mode resolves the WAL path through a new metrics::energy_wal::resolve_wal_path(...) helper that follows the same precedence. view::event_handler::export_users_csv calls paths::cache_dir() directly. The hand-rolled cache_dir_for_all_smi() in event_handler.rs is deleted.
  • Energy-WAL signatures cleaned up. spawn_wal_flush_task / flush_cycle / compact_wal now take PathBuf / &Path instead of String / &str, dropping the per-cycle to_string() and Path::new(...) re-wrapping.
  • Best-effort one-time migration. New src/common/cache_migration.rs is called once from main() before any subcommand touches the cache. Linux without $XDG_CACHE_HOME is a no-op (old == new). On macOS, Windows, and Linux with $XDG_CACHE_HOME set it moves ~/.cache/all-smi/records and ~/.cache/all-smi/energy-wal.bin into the new root only when the destination is empty (never overwrites) and refuses to follow a pre-planted symlink at the source (same threat model as the writer-side O_NOFOLLOW defences). The users-CSV exports are deliberately not migrated — they carry per-second timestamps and bulk-renaming hundreds of files would be more disruptive than helpful.
  • config print shows the resolved on-disk path when no override is set, both in TOML and JSON outputs, so operators can confirm at a glance where data will land.
  • Docs. README updates the [energy] wal_path, [record] output_dir, --output and users-CSV export rows + the WAL persistence paragraph + the security note about the users-CSV export; manpage updates the FILES and ENERGY ACCOUNTING sections; RecordArgs::output long_help and the example config.toml text spell out the per-platform cache locations. The v0.21.0 changelog entry gets a closing sentence describing the unification and the migration.

Migration

  • Linux without $XDG_CACHE_HOME (the common case): dirs::cache_dir() == ~/.cache, so the old path and the new path are identical and the migration helper is a no-op.
  • macOS, Windows, and Linux with $XDG_CACHE_HOME set: the cache root relocates. Operators will see one-time all-smi: migrated legacy cache <old> -> <new> lines on stderr at startup as records/ and energy-wal.bin are moved into the new root. The migration never overwrites an existing destination — if you have data at both the old and the new path, the old data is left in place for manual reconciliation.
  • Symlink-planted sources are refused with a warning on stderr (all-smi: skipping cache migration of <path> (is a symlink)), matching the writer-side O_NOFOLLOW rationale.
  • Cross-filesystem moves are not silently doubled — if $XDG_CACHE_HOME points at a tmpfs while ~ is on persistent disk, std::fs::rename will return EXDEV, the helper logs the error, and recordings stay where they were. Operators can move them by hand.

New tests

  • paths::tests::cache_dir_ends_with_app_name — sanity for the new helper.
  • record::tests::resolve_output_no_cli_no_config_uses_platform_cache_dir — guards the issue-cache: unify record/energy-WAL/users-CSV paths via platform-aware dirs::cache_dir() (Scope B from #223) #229 promise that record output flows through paths::cache_dir() (skipped on hosts where cache_dir() returns None, same pattern as config_dir_ends_with_app_name).
  • cache_migration::tests (5 tests): try_move_skips_when_source_missing, try_move_renames_file_when_dst_missing, try_move_renames_directory_when_dst_missing, try_move_no_op_when_dst_exists, try_move_refuses_symlink_source (Unix-only — symlink creation requires it).
  • Existing record::tests resolver tests rewritten for the Option<String> shape; config::tests::energy_config_defaults_match_issue_spec and the ALL_SMI_ENERGY_WAL_PATH env test now assert None / Some(...).

Security hardening preserved

The writer-side O_NOFOLLOW + mode 0600 defences in src/record/writer.rs, src/metrics/energy_wal.rs::open_secure_append, and src/view/event_handler.rs::open_export_secure apply unchanged to the new resolved paths — only the resolver tier changes. The migration helper has its own defence: it refuses to rename through a symlink at the source.

Test plan

  • cargo check --bin all-smi --tests
  • RUSTFLAGS="-L /tmp/drmlinks" cargo test --bin all-smi paths::tests::
  • RUSTFLAGS="-L /tmp/drmlinks" cargo test --bin all-smi record::tests::
  • RUSTFLAGS="-L /tmp/drmlinks" cargo test --bin all-smi cache_migration::tests::
  • RUSTFLAGS="-L /tmp/drmlinks" cargo test --bin all-smi config::tests::
  • RUSTFLAGS="-L /tmp/drmlinks" cargo test --bin all-smi config_file::tests::
  • RUSTFLAGS="-L /tmp/drmlinks" cargo test --bin all-smi metrics::energy_wal::
  • RUSTFLAGS="-L /tmp/drmlinks" cargo test --bin all-smi --test config_file_integration_test (14/14)
  • RUSTFLAGS="-L /tmp/drmlinks" cargo clippy --bin all-smi --tests -- -D warnings
  • cargo fmt --check

References #223 and #227. Closes #229.

Route every `all-smi` cache writer — record output, energy WAL, and the users-tab CSV export — through a single platform-aware helper built on `dirs::cache_dir()`, retire the three hard-coded `~/.cache/all-smi/...` literals, and delete the hand-rolled `cache_dir_for_all_smi()` reimplementation in `src/view/event_handler.rs`.

The new `crate::common::paths::cache_dir()` helper mirrors the existing `config_dir()` so the cache layer is internally consistent with the config layer. `record.output_dir` and `energy.wal_path` now store `Option<String>` — `None` means "use the platform helper", `Some(s)` is an explicit operator override resolved through `expand_tilde`. Consumers (`record::RecorderOptions::from_args_with_settings`, `api::server::run_api_mode`) call the helper when no override is present.

Adds `crate::common::cache_migration::migrate_legacy_cache_paths`, called from `main()` before any cache file is touched. On Linux without `$XDG_CACHE_HOME` the old and new paths are identical and it is a no-op; on macOS, Windows, and Linux with `$XDG_CACHE_HOME` set, it best-effort-renames `~/.cache/all-smi/{records,energy-wal.bin}` into the new root only when the destination is empty (never overwrites) and refuses to follow a pre-planted symlink at the source (same threat model as the writer-side `O_NOFOLLOW` defences). The users-CSV exports are deliberately not migrated — they carry per-second timestamps and bulk-renaming hundreds of files would be more disruptive than helpful.

Tests added: `paths::tests::cache_dir_ends_with_app_name`, `record::tests::resolve_output_no_cli_no_config_uses_platform_cache_dir`, and a five-test `cache_migration::tests` module (`try_move_skips_when_source_missing`, `try_move_renames_file_when_dst_missing`, `try_move_renames_directory_when_dst_missing`, `try_move_no_op_when_dst_exists`, `try_move_refuses_symlink_source`). Existing `record::tests` resolver tests updated for the `Option<String>` shape; `config::tests::energy_config_defaults_match_issue_spec` and the `ALL_SMI_ENERGY_WAL_PATH` env test updated to assert `None` / `Some(...)`.

Docs: README updates the `[energy] wal_path`, `[record] output_dir`, `--output` and users-CSV export rows; manpage updates the FILES and ENERGY ACCOUNTING sections; the `record --output` `long_help` and the example `config.toml` text spell out the per-platform cache locations. The v0.21.0 changelog gets a closing sentence describing the unification and the migration. The `config print` / `config print --json` renderer now shows the resolved on-disk path when no override is set so operators can confirm at a glance where data will land.

Closes #229. Builds on #227 (Scope A of #223). The writer-side hardening (`O_NOFOLLOW`, mode `0600`, symlink refusal at rotation) lives in `src/record/writer.rs`, `src/metrics/energy_wal.rs::open_secure_append`, and `src/view/event_handler.rs::open_export_secure` and applies unchanged to the new resolved paths. `ALL_SMI_ENERGY_WAL_PATH` and `ALL_SMI_RECORD_OUTPUT_DIR` env-var support and the TOML key names are preserved.
@inureyes inureyes added status:review Under review type:enhancement New feature or request priority:low Low priority issue labels May 24, 2026
inureyes added 2 commits May 24, 2026 16:46
The ALL_SMI_ENERGY_WAL_PATH help line still showed the Linux-only
~/.cache/all-smi/energy-wal.bin default, while the man page, README
footnote, and RecordArgs long_help already describe the per-platform
resolution. Use <platform cache dir>/all-smi/energy-wal.bin instead so
all four doc surfaces describe the same default for #229.
Apply the two LOW defense-in-depth consistency fixes flagged by the security checker:

**L1 — record resolver (src/record/mod.rs):** Add .map(str::trim) before .filter(!is_empty()) on the configured output_dir chain. A TOML value of "   " (spaces only) now falls through to the platform cache helper instead of being honored as a literal relative directory, matching how resolve_wal_path() already handles whitespace-only wal_path values via str::trim.

**L2 — TOML loader (src/common/config_apply.rs):** apply_file_energy and apply_file_record now trim and reject whitespace-only strings before writing to settings.energy.wal_path and settings.record.output_dir respectively, matching the existing behavior of apply_env_record which already calls trim().is_empty(). The collapsed let-chain form (if let Some(x) = ... && !x.trim().is_empty()) satisfies clippy's collapsible_if lint.

**Tests added:**
- src/record/mod.rs: resolve_output_whitespace_only_config_dir_falls_through — asserts a whitespace-only output_dir does not leak into the resolved output path.
- tests/config_file_integration_test.rs: whitespace_only_wal_path_and_output_dir_treated_as_unset — loads a TOML with wal_path = "   " and output_dir = "   " and asserts both fields resolve to None.

**Docs:** Changelog entry for the Users tab updated to reference the platform-aware cache path (via [^cache] footnote) instead of the Linux-only literal ~/.cache/all-smi/users-<timestamp>.csv.

All targeted tests pass (1172 unit, 15 integration), clippy -D warnings clean, cargo fmt --check clean.
@inureyes

Copy link
Copy Markdown
Member Author

PR Finalization Complete

Summary

L1 and L2 LOW security-checker fixes applied in commit b169549.

Tests added/updated:

  • src/record/mod.rs: new resolve_output_whitespace_only_config_dir_falls_through — asserts a whitespace-only output_dir does not propagate as a literal path; total record resolver tests: 10.
  • tests/config_file_integration_test.rs: new whitespace_only_wal_path_and_output_dir_treated_as_unset — loads TOML with wal_path = " " and output_dir = " " and asserts both resolve to None; total integration tests: 15.

Documentation:

  • README changelog entry for the Users tab corrected: ~/.cache/all-smi/users-<timestamp>.csv replaced with <platform cache dir>/all-smi/users-<timestamp>.csv [^cache] to match the platform-aware semantics introduced by this PR.
  • All other doc surfaces (manpage FILES + ENERGY ACCOUNTING, cli.rs ENERGY_HELP, RecordArgs::output long_help, config example.rs, [^cache] footnote) verified correct — no Linux-only literal paths remaining.
  • No localized READMEs exist in the repository (only README.md).

Code quality:

  • cargo fmt --check: clean
  • cargo clippy --bin all-smi --tests -- -D warnings: clean (collapsed nested-if to let-chain &&-form per clippy collapsible_if)
  • 1172 unit tests pass, 15 integration tests pass, 1 ignored (platform-specific skip)

L3 (dst.exists() vs dst.symlink_metadata() in cache_migration.rs) left as-is per the brief — the security checker confirmed no vulnerability; the tightening is follow-up if desired.

@inureyes inureyes added status:done Completed and removed status:review Under review labels May 24, 2026
@inureyes inureyes merged commit eb5d856 into main May 24, 2026
2 checks passed
@inureyes inureyes deleted the feat/229-unify-cache-paths branch May 24, 2026 08:05
@inureyes inureyes self-assigned this May 24, 2026
inureyes added a commit that referenced this pull request May 24, 2026
Refuse a symlinked legacy cache root before composing migration targets, and treat any destination filesystem entry, including a dangling symlink, as occupied so the best-effort migration preserves the no-overwrite invariant.

Refs #229

Refs #230
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

priority:low Low priority issue status:done Completed type:enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

cache: unify record/energy-WAL/users-CSV paths via platform-aware dirs::cache_dir() (Scope B from #223)

1 participant