diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index a5d6e86..c69d484 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -1,7 +1,7 @@ ````markdown # RCDir Development Guidelines -Auto-generated from all feature plans. Last updated: 2026-04-11 +Auto-generated from all feature plans. Last updated: 2026-04-19 ## Active Technologies - Rust stable (edition 2024, toolchain 1.85+) + `windows` crate 0.62 (Win32 API), `widestring` 1 (UTF-16) (003-file-icons) @@ -10,6 +10,8 @@ Auto-generated from all feature plans. Last updated: 2026-04-11 - N/A (filesystem enumeration, no persistent storage) (004-tree-view) - Rust stable (latest stable release) + `windows` crate for Win32 console API; standard library for file I/O (`std::fs::read`) (006-config-file-support) - Single flat file (`%USERPROFILE%\.rcdirconfig`), UTF-8 with optional BOM (006-config-file-support) +- Rust stable (latest stable release, per rust-toolchain.toml) + `windows` crate (Win32 API: `CreateFileW`, `DeviceIoControl`, `FSCTL_GET_REPARSE_POINT`) (007-symlink-junction-targets) +- N/A (filesystem reads only, no persistent state) (007-symlink-junction-targets) - Rust stable (1.93.0), Edition 2024 + `windows` crate (Win32 APIs), `widestring` (UTF-16 interop) (master) @@ -29,9 +31,9 @@ cargo test; cargo clippy Rust stable (1.93.0), Edition 2024: Follow standard conventions ## Recent Changes +- 007-symlink-junction-targets: Added Rust stable (latest stable release, per rust-toolchain.toml) + `windows` crate (Win32 API: `CreateFileW`, `DeviceIoControl`, `FSCTL_GET_REPARSE_POINT`) - 006-config-file-support: Added Rust stable (latest stable release) + `windows` crate for Win32 console API; standard library for file I/O (`std::fs::read`) -- 004-tree-view: Added Rust stable (edition 2024) + `windows` crate (Win32 API), `widestring` crate, Rust std library only (no third-party libraries) -- 004-tree-view: Added Rust stable (edition 2024) + `windows` crate (Win32 API), `widestring` crate, Rust std library only (no third-party libraries) +Fix- 004-tree-view: Added Rust stable (edition 2024) + `windows` crate (Win32 API), `widestring` crate, Rust std library only (no third-party libraries) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index ee7b0c8..90d6b7e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -178,6 +178,13 @@ console.print_error(&format!("Error: {}", msg)); - **No test may run the real `rcdir` binary** — test the library functions directly with mocked dependencies - Temp files are acceptable **only** in explicitly marked integration tests, never in unit tests +### Output Parity Tests — Required for All Features +- Every user-visible feature or bug fix MUST include output parity tests in `tests/output_parity.rs` +- Parity tests run both `rcdir` and `tcdir` with the same arguments and assert byte-identical output +- These are an **allowed exception** to the unit test isolation rules above — they run real binaries +- Parity tests gracefully skip when `tcdir.exe` is not available (CI environments) +- When adding a new feature, add parity test cases covering all affected display modes (normal, tree, wide, bare as applicable) + --- ## Communication Rules @@ -222,6 +229,27 @@ console.print_error(&format!("Error: {}", msg)); - `cargo check` — quick compilation verification - `cargo clippy` — lint checking +### Toolchain Currency +- **ALWAYS** run `rustup update stable` before starting work to ensure the local toolchain matches CI +- CI uses `dtolnay/rust-toolchain@stable` which installs the latest stable Rust on every run +- New stable releases (every 6 weeks) can introduce new clippy lints that break `-D warnings` +- A toolchain mismatch between local and CI is the most common cause of "works locally, fails in CI" + +### Pre-Push Checklist +- **ALWAYS** run `cargo clippy -- -D warnings` and verify zero errors before pushing +- **ALWAYS** run `cargo test` and verify all tests pass before pushing +- If clippy introduces new warnings after a toolchain update, fix them before pushing + +### Pre-Commit Gates +- **ALL** tests MUST pass before committing (`cargo test`) +- Clippy MUST be clean (`cargo clippy -- -D warnings`) before committing +- Build MUST succeed with no errors before committing + +### Commit Frequency +- During spec implementation, commit **at least once per completed phase** +- Each commit must leave the codebase in a compilable, tests-passing, clippy-clean state +- Do not batch an entire feature into a single commit + ### Build Integration - Always build after making changes using the build task or `Build.ps1` - Fix all clippy warnings before considering task complete @@ -255,7 +283,7 @@ console.print_error(&format!("Error: {}", msg)); --- -*Last Updated: 2026-02-16* +*Last Updated: 2026-04-20* *These rules apply globally to all RCDir work* ```` diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index 02664da..680ad0f 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -64,8 +64,9 @@ All user-facing output MUST follow established patterns: - **Error Messages**: Errors go to stderr; user-facing messages MUST be clear, actionable, and consistent in tone - **Help System**: All features MUST be documented in `-?` help output and `--env`/`--config` where applicable - **Backward Compatibility**: Maintain compatibility with TCDir's command-line interface +- **Output Parity (NON-NEGOTIABLE)**: Every feature and bug fix MUST produce byte-identical visible output to TCDir for the same inputs. Output parity tests in `tests/output_parity.rs` MUST be added or updated for every user-visible change. These parity tests are an allowed exception to the test isolation rule — they run real binaries and compare output. -**Rationale**: RCDir is a Rust port of TCDir; users expect consistent behavior with the original. +**Rationale**: RCDir is a Rust port of TCDir; users expect identical behavior with the original. Output parity tests are the final verification that the port is correct. ### IV. Performance Requirements @@ -149,6 +150,6 @@ This constitution supersedes all ad-hoc practices. All code changes MUST verify **Guidance Reference**: See `.github/copilot-instructions.md` for detailed runtime development guidance and code style rules. -**Version**: 1.2.0 | **Ratified**: 2026-02-07 | **Last Amended**: 2026-04-20 +**Version**: 1.3.0 | **Ratified**: 2026-02-07 | **Last Amended**: 2026-04-20 ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index f94239b..b6e6886 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ All notable changes to RCDir are documented in this file. +## [5.4] - 2026-04-20 + +### Added + +- Symlink, junction, and AppExecLink target display: `filename → target` in normal and tree modes + - Junctions show resolved target path with `\??\` device prefix stripped + - File and directory symlinks show target path as-stored (relative or absolute) + - AppExecLink entries (e.g., `python.exe` in WindowsApps) show resolved executable path + - Arrow (`→`) uses Information color; target uses the filename's own color + - Wide and bare modes unaffected (no target display) + - Graceful degradation: inaccessible reparse data shows filename without target + - Zero overhead for non-reparse files (single attribute flag check) +- 3 new output parity tests for reparse target display (normal, tree, AppExecLink) +- 469 unit tests (up from 435) + ## [5.3.1403] - 2026-04-11 ### Added diff --git a/README.md b/README.md index 0fc9179..b140b71 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ It's designed as a practical `dir`-style command with useful defaults (color by | Version | Highlights | |---------|------------| +| **5.4** | Symlink, junction, and AppExecLink target display (`→ target`) | | **5.3** | Config file support (`.rcdirconfig`), `--config` diagnostics, `--settings` merged view | | **5.2** | Interactive PowerShell alias configuration (`--set-aliases`, `--get-aliases`, `--remove-aliases`) | | **5.1** | `--Tree` hierarchical directory view with depth control | @@ -37,6 +38,7 @@ Hat tip to [Chris Kirmse](https://github.com/ckirmse) whose excellent [ZDir](htt | Multi-threaded enumeration | — | ✅ | — | — | | Native Windows (no WSL/MSYS) | ✅ | ✅ | ⚠️ | ⚠️ | | Familiar `dir` switch syntax | ✅ | ✅ | — | — | +| Symlink/junction target display | — | ✅ | ✅ | ✅ | | ARM64 native binary | ✅ | ✅ | — | — | | NTFS alternate data streams | ✅ | ✅ | — | — | | Configurable via environment variable | — | ✅ | — | — | diff --git a/Version.toml b/Version.toml index 0e6d727..d05de05 100644 --- a/Version.toml +++ b/Version.toml @@ -1,5 +1,5 @@ # RCDir version — build number auto-incremented by build.rs on every compile. # Major and minor are updated manually. major = 5 -minor = 3 -build = 1403 +minor = 4 +build = 1404 diff --git a/specs/007-symlink-junction-targets/data-model.md b/specs/007-symlink-junction-targets/data-model.md new file mode 100644 index 0000000..0d00659 --- /dev/null +++ b/specs/007-symlink-junction-targets/data-model.md @@ -0,0 +1,108 @@ +# Data Model: Symlink & Junction Target Display + +## Entities + +### ReparseDataBuffer (manual struct definition) + +Mirrors the Windows `REPARSE_DATA_BUFFER` layout. Not available in the `windows` crate; defined manually. + +```rust +#[repr(C)] +struct ReparseDataBufferHeader { + reparse_tag: u32, // IO_REPARSE_TAG_MOUNT_POINT, SYMLINK, or APPEXECLINK + reparse_data_length: u16, // Length of data after this header + reserved: u16, // Must be 0 +} +``` + +**Junction / Mount Point buffer** (after header): +```rust +#[repr(C)] +struct MountPointReparseBuffer { + substitute_name_offset: u16, + substitute_name_length: u16, + print_name_offset: u16, + print_name_length: u16, + // Followed by PathBuffer: [u16; N] containing both strings +} +``` + +**Symlink buffer** (after header): +```rust +#[repr(C)] +struct SymbolicLinkReparseBuffer { + substitute_name_offset: u16, + substitute_name_length: u16, + print_name_offset: u16, + print_name_length: u16, + flags: u32, // SYMLINK_FLAG_RELATIVE = 0x00000001 + // Followed by PathBuffer: [u16; N] containing both strings +} +``` + +**AppExecLink buffer** (after header): +``` +[u32 version] // Must be 3 +[NUL-terminated UTF-16] // Package family name +[NUL-terminated UTF-16] // App user model ID +[NUL-terminated UTF-16] // Target executable path ← displayed +``` + +### FileInfo (existing, modified) + +| Field | Type | Change | Description | +|-------|------|--------|-------------| +| `reparse_tag` | `u32` | Existing | Already populated from `WIN32_FIND_DATA.dwReserved0` | +| `reparse_target` | `String` | **NEW** | Resolved target path; empty if not a supported reparse point or resolution failed | + +### Supported Reparse Tags + +| Constant | Value | Type | Target Source | +|----------|-------|------|---------------| +| `IO_REPARSE_TAG_MOUNT_POINT` | `0xA000_0003` | Junction | PrintName (preferred) or SubstituteName with `\??\` stripped | +| `IO_REPARSE_TAG_SYMLINK` | `0xA000_000C` | Symlink | PrintName (preferred) or SubstituteName (strip `\??\` for absolute only) | +| `IO_REPARSE_TAG_APPEXECLINK` | `0x8000_001B` | App alias | Third NUL-terminated string in version-3 buffer | + +### Display Format + +``` +{filename} → {target_path} + ^ ^ + | └─ filename's own color attribute + └─ Information color attribute (U+2192 RIGHTWARDS ARROW) +``` + +- Exactly one space before and after the arrow +- Arrow character: `→` (U+2192) +- Only displayed in normal mode and tree mode +- NOT displayed in wide mode or bare mode + +## Relationships + +``` +FileInfo (1) ──has──> (0..1) reparse_target: String + │ + ├── reparse_tag determines which parser to invoke + ├── FILE_ATTRIBUTE_REPARSE_POINT flag gates resolution + └── Empty string = not resolved / not applicable / error + +ReparseResolver + ├── resolve_reparse_target() ──calls──> CreateFileW + DeviceIoControl + ├── parse_junction_buffer() ──reads──> MountPointReparseBuffer + ├── parse_symlink_buffer() ──reads──> SymbolicLinkReparseBuffer + ├── parse_app_exec_link_buffer() ──reads──> GenericReparseBuffer + └── strip_device_prefix() ──strips──> "\??\" prefix +``` + +## Validation Rules + +- `reparse_tag` must match one of the three supported tags before attempting resolution +- `FILE_ATTRIBUTE_REPARSE_POINT` attribute must be set (early exit otherwise) +- Buffer size must be ≥ header size (8 bytes) before parsing +- PrintName/SubstituteName offsets + lengths must not exceed buffer bounds +- AppExecLink version must be 3; reject other versions silently +- NUL terminators must be present within remaining buffer for AppExecLink strings + +## State Transitions + +N/A — no state machine. Resolution is a one-shot operation during enumeration. diff --git a/specs/007-symlink-junction-targets/plan.md b/specs/007-symlink-junction-targets/plan.md new file mode 100644 index 0000000..73f21d1 --- /dev/null +++ b/specs/007-symlink-junction-targets/plan.md @@ -0,0 +1,73 @@ +# Implementation Plan: Symlink & Junction Target Display + +**Branch**: `007-symlink-junction-targets` | **Date**: 2026-04-19 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from `/specs/007-symlink-junction-targets/spec.md` + +## Summary + +Display `→ target_path` after symlinks, junctions, and AppExecLinks in normal and tree mode listings. Requires a new reparse point resolver module that reads reparse data via `DeviceIoControl` + `FSCTL_GET_REPARSE_POINT`, parses the three supported buffer formats (junction, symlink, AppExecLink), and stores the resolved target in `FileInfo`. Display integration adds the arrow (Information color) and target path (filename color) after the entry name. Ported from TCDir spec 007. + +## Technical Context + +**Language/Version**: Rust stable (latest stable release, per rust-toolchain.toml) +**Primary Dependencies**: `windows` crate (Win32 API: `CreateFileW`, `DeviceIoControl`, `FSCTL_GET_REPARSE_POINT`) +**Storage**: N/A (filesystem reads only, no persistent state) +**Testing**: `cargo test` — pure-function buffer parsing tests with synthetic byte arrays +**Target Platform**: Windows 10/11, x64 and ARM64 +**Project Type**: CLI application (directory lister) +**Performance Goals**: Zero overhead for non-reparse files (single attribute flag check); <100µs per reparse point resolution +**Constraints**: Stack-allocated 16KB buffer for reparse data; no heap allocation in hot path +**Scale/Scope**: Typically 0–5 reparse points per directory listing; negligible impact on overall listing time + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. Code Quality | ✅ PASS | `Result` for all Win32 calls; no `unwrap()` in production; borrowing preferred; idiomatic Rust patterns | +| II. Testing Discipline | ✅ PASS | Pure-function buffer parsers tested with synthetic byte arrays; no file system dependency in unit tests | +| III. User Experience Consistency | ✅ PASS | Arrow uses Information color (matches TCDir); target uses filename color; output parity with TCDir verified | +| IV. Performance Requirements | ✅ PASS | Single flag check for non-reparse files; stack-allocated 16KB buffer; no heap allocation in resolver | +| V. Simplicity & Maintainability | ✅ PASS | Single new module (reparse_resolver); pure parsing functions separated from I/O; minimal display changes | + +## Project Structure + +### Documentation (this feature) + +```text +specs/[###-feature]/ +├── plan.md # This file (/speckit.plan command output) +├── research.md # Phase 0 output (/speckit.plan command) +├── data-model.md # Phase 1 output (/speckit.plan command) +├── quickstart.md # Phase 1 output (/speckit.plan command) +├── contracts/ # Phase 1 output (/speckit.plan command) +└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan) +``` + +### Source Code (repository root) + +```text +src/ +├── file_info.rs # Add reparse_target: String field +├── reparse_resolver.rs # NEW: Win32 reparse data reading + buffer parsing +├── directory_lister.rs # Call resolve_reparse_target() in add_match_to_list() +├── multi_threaded_lister.rs # Same integration as directory_lister +└── results_displayer/ + ├── normal.rs # Append → target after filename + └── tree.rs # Append → target after filename + +tests/ +└── (inline #[cfg(test)]) # Pure-function buffer parsing tests in reparse_resolver.rs +``` + +**Structure Decision**: Single new module `reparse_resolver.rs` at src/ root level. Buffer parsing functions are pure (no I/O), testable inline. Display changes are minimal additions to existing normal and tree displayers. + +## Complexity Tracking + +> **Fill ONLY if Constitution Check has violations that must be justified** + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| [e.g., 4th project] | [current need] | [why 3 projects insufficient] | +| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] | diff --git a/specs/007-symlink-junction-targets/quickstart.md b/specs/007-symlink-junction-targets/quickstart.md new file mode 100644 index 0000000..302dec5 --- /dev/null +++ b/specs/007-symlink-junction-targets/quickstart.md @@ -0,0 +1,75 @@ +# Quickstart: Symlink & Junction Target Display + +## What This Feature Does + +Displays `→ target_path` after symlinks, junctions, and AppExecLink entries in `rcdir` listings (normal and tree modes). + +## Key Files + +| File | Role | +|------|------| +| `src/reparse_resolver.rs` | **NEW** — Win32 reparse data reading + buffer parsing | +| `src/file_info.rs` | Add `reparse_target: String` field | +| `src/directory_lister.rs` | Call resolver in `add_match_to_list()` | +| `src/multi_threaded_lister.rs` | Same integration as directory_lister | +| `src/results_displayer/normal.rs` | Append `→ target` after filename | +| `src/results_displayer/tree.rs` | Append `→ target` after filename | + +## Architecture at a Glance + +``` +Enumeration (directory_lister / multi_threaded_lister) + │ + ├── For each file: check FILE_ATTRIBUTE_REPARSE_POINT flag + │ └── If set: call reparse_resolver::resolve_reparse_target() + │ ├── CreateFileW (open link itself, not target) + │ ├── DeviceIoControl(FSCTL_GET_REPARSE_POINT) + │ └── Dispatch to parse_{junction,symlink,app_exec_link}_buffer() + │ + └── Store result in file_info.reparse_target (empty string on failure) + +Display (results_displayer/normal.rs, tree.rs) + │ + └── If reparse_target is non-empty: + ├── Print " → " with Information color + └── Print target path with filename's color +``` + +## How to Build & Test + +```powershell +cargo check # Quick compilation check +cargo test # Run all tests including new buffer parsing tests +cargo clippy -- -D warnings # Lint check +``` + +## Reparse Tag Constants + +```rust +const IO_REPARSE_TAG_MOUNT_POINT: u32 = 0xA000_0003; // Junction +const IO_REPARSE_TAG_SYMLINK: u32 = 0xA000_000C; // Symlink +const IO_REPARSE_TAG_APPEXECLINK: u32 = 0x8000_001B; // App exec alias +``` + +## Display Output Examples + +``` +Normal mode: + 04/19/2026 10:00 AM Projects → C:\Dev\Projects + 04/19/2026 10:00 AM 0 config.yml → ..\shared\config.yml + 04/19/2026 10:00 AM 0 python.exe → C:\Program Files\WindowsApps\...\python3.12.exe + +Tree mode: + ├── Projects → C:\Dev\Projects + ├── config.yml → ..\shared\config.yml + └── python.exe → C:\Program Files\WindowsApps\...\python3.12.exe +``` + +## Testing Strategy + +Pure-function buffer parsers tested with synthetic byte arrays: +- `build_junction_buffer(print_name, substitute_name) -> Vec` +- `build_symlink_buffer(print_name, substitute_name, flags) -> Vec` +- `build_app_exec_link_buffer(version, pkg_id, app_id, target_exe) -> Vec` + +No filesystem mocking needed — Win32 I/O integration tested manually. diff --git a/specs/007-symlink-junction-targets/research.md b/specs/007-symlink-junction-targets/research.md new file mode 100644 index 0000000..615ea8c --- /dev/null +++ b/specs/007-symlink-junction-targets/research.md @@ -0,0 +1,123 @@ +# Research: Symlink & Junction Target Display + +## Decision 1: Reparse Buffer Parsing Approach + +**Decision**: Define `REPARSE_DATA_BUFFER` structure manually in Rust; do not depend on WDK or undocumented crate bindings. + +**Rationale**: The `REPARSE_DATA_BUFFER` struct is not exposed by the `windows` crate (it comes from the WDK/ntifs.h). TCDir defines it manually in C++. The structure is stable and well-documented in MSDN. Manual definition avoids a WDK dependency and matches the proven TCDir approach. + +**Alternatives considered**: +- `windows` crate WDK bindings — not available in the standard `windows` crate; would require `windows-sys` or WDK feature flags that add complexity +- `std::fs::read_link()` — only works for symlinks, not junctions or AppExecLinks; also resolves the target (we want the raw stored path) +- Third-party crate (e.g., `junction`) — adds dependency; doesn't cover AppExecLinks; doesn't give raw buffer access + +## Decision 2: Target Resolution Timing + +**Decision**: Resolve reparse targets at enumeration time (in `add_match_to_list`), not at display time. + +**Rationale**: This matches TCDir's architecture and keeps the display layer I/O-free. The resolver opens a file handle, which is a blocking Win32 call — doing this during display would interleave I/O with console output. Resolving during enumeration means: +- Multi-threaded lister: each worker resolves targets for its own files (no contention) +- Display code remains pure formatting (no fallible I/O) +- Target string is readily available when needed + +**Alternatives considered**: +- Display-time resolution — would require error handling in display code and interleave I/O with output; rejected +- Lazy resolution (resolve on first access) — unnecessary complexity; reparse points are rare enough that eager resolution has negligible cost + +## Decision 3: Win32 API Call Sequence + +**Decision**: Use `CreateFileW` → `DeviceIoControl(FSCTL_GET_REPARSE_POINT)` → parse buffer. + +**Rationale**: This is the standard Windows API for reading reparse data. The `CreateFileW` call uses: +- `dwDesiredAccess = 0` — no read/write access needed, just FSCTL +- `FILE_FLAG_OPEN_REPARSE_POINT` — open the link itself, not follow it +- `FILE_FLAG_BACKUP_SEMANTICS` — required for opening directories + +The `FSCTL_GET_REPARSE_POINT` ioctl returns the raw reparse data buffer. This is the same approach used by TCDir and is well-tested. + +**Alternatives considered**: +- `GetFinalPathNameByHandle` — resolves to final target but loses relative path information; not suitable for FR-004 (display paths as-stored) +- `NtQueryInformationFile` — undocumented/semi-documented NT API; unnecessary when FSCTL works + +## Decision 4: Buffer Allocation Strategy + +**Decision**: Stack-allocate a 16KB buffer (`MAXIMUM_REPARSE_DATA_BUFFER_SIZE = 16384`). + +**Rationale**: Reparse data buffers are at most 16KB per NTFS specification. Stack allocation: +- Avoids heap allocation per reparse point +- Buffer is used only within the resolver function scope +- 16KB is safe for stack usage (typical thread stack is 1–8MB) +- Matches TCDir's approach exactly + +**Alternatives considered**: +- Heap-allocated `Vec` — unnecessary allocation; rejected +- Smaller initial buffer with retry — reparse data is always ≤16KB so retry logic is pointless + +## Decision 5: PrintName vs SubstituteName Preference + +**Decision**: Prefer `PrintName` field; fall back to `SubstituteName` with `\??\` prefix stripping. + +**Rationale**: +- `PrintName` is the user-friendly display name (no device prefix, clean path) +- `SubstituteName` is the NT-internal name, often prefixed with `\??\` +- Most reparse points have a non-empty PrintName, but some (especially older junctions created by certain tools) may only have SubstituteName +- The `\??\` prefix must be stripped from SubstituteName to produce a user-readable path +- For relative symlinks (SYMLINK_FLAG_RELATIVE = 0x1), do NOT strip prefix from SubstituteName + +**Alternatives considered**: +- Always use SubstituteName — would require stripping logic for all entries; PrintName is cleaner when available +- Always use PrintName, fail if empty — too fragile; SubstituteName fallback handles edge cases + +## Decision 6: AppExecLink Buffer Format + +**Decision**: Parse AppExecLink as version-3 generic reparse buffer with three NUL-terminated wide strings. + +**Rationale**: AppExecLink reparse points (IO_REPARSE_TAG_APPEXECLINK = 0x8000001B) use a non-standard buffer format: +- First 4 bytes: version ULONG (must be 3) +- Followed by three NUL-terminated UTF-16 strings: + 1. Package family name + 2. App user model ID + 3. Target executable path (this is what we display) + +Version check is critical — only version 3 is documented/supported. Return empty string for other versions. + +**Alternatives considered**: +- Resolve via `SHGetKnownFolderPath` + registry — overly complex; the target exe is right there in the buffer +- Skip AppExecLink support — leaves `python.exe`, `winget.exe`, etc. in WindowsApps directory without targets; poor UX + +## Decision 7: Color Scheme + +**Decision**: Arrow (`→`) uses `Information` attribute; target path uses the same color as the source filename. + +**Rationale**: This matches TCDir's implementation (FR-006, FR-007). The Information color (typically cyan) provides visual separation between the filename and target. Using the filename's own color for the target maintains visual grouping — the arrow connects the name to its destination. + +**Alternatives considered**: +- Extension-based color for file symlink targets — was in early TCDir spec (FR-008) but superseded by FR-007; simpler and more consistent to use the source filename color +- Default/white color for target — loses visual connection between link and destination + +## Decision 8: Error Handling Philosophy + +**Decision**: All failures return empty string (no target displayed). No error messages, no stderr output. + +**Rationale**: Per FR-011, graceful degradation is required. If reparse data can't be read (access denied, corrupted data, unsupported format), the file still displays normally — just without the `→ target` suffix. This is a cosmetic enhancement, not a critical feature, so silent failure is appropriate. + +**Alternatives considered**: +- Display `→ ` or `→ ???` — adds noise; user can't act on it anyway +- Log to stderr on failure — violates FR-011; clutters output for non-actionable errors + +## Decision 9: Module Structure + +**Decision**: Single module `src/reparse_resolver.rs` with public pure-parsing functions and one public I/O function. + +**Rationale**: Matches the single-responsibility principle. The module exposes: +- `resolve_reparse_target(dir_path, file_info) -> String` — the I/O function (opens file, reads buffer, dispatches to parser) +- `parse_junction_buffer(buffer) -> String` — pure function, testable +- `parse_symlink_buffer(buffer) -> String` — pure function, testable +- `parse_app_exec_link_buffer(buffer) -> String` — pure function, testable +- `strip_device_prefix(path) -> String` — pure function, testable + +Pure functions enable comprehensive unit testing with synthetic byte arrays — no filesystem mocking needed. + +**Alternatives considered**: +- Embed in `file_info.rs` — too many concerns; file_info is a data struct, not a resolver +- Separate module per reparse type — over-engineering for ~200 lines of code total diff --git a/specs/007-symlink-junction-targets/spec.md b/specs/007-symlink-junction-targets/spec.md new file mode 100644 index 0000000..2ec6f7c --- /dev/null +++ b/specs/007-symlink-junction-targets/spec.md @@ -0,0 +1,137 @@ +# Feature Specification: Symlink & Junction Target Display + +**Feature Branch**: `007-symlink-junction-targets` +**Created**: 2026-04-19 +**Status**: Draft +**Input**: User description: "Display symlink and junction targets in directory listings" + +## Clarifications + +### Session 2026-04-19 + +- Q: What exact spacing format between filename and arrow/target? → A: `filename → target` (single space each side of arrow) +- Q: How should non-junction/non-symlink reparse tags be handled? → A: Support AppExecLink (show resolved target exe path); silently ignore all other reparse types (WCI, DEDUP, AF_UNIX, LX_SYMLINK, LX_FIFO, CLOUD already handled) + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 — Normal Mode Target Display (Priority: P1) + +A user runs `rcdir` in a directory containing junctions and symlinks. After each junction or symlink name, they see a `→` arrow followed by the target path. This tells them at a glance where each link points without needing to run a separate command. + +**Why this priority**: Core value proposition — showing link targets is the entire feature. Without this, there is nothing. + +**Independent Test**: Create a directory with a junction and a file symlink. Run `rcdir` and verify the `→ target` suffix appears after each link entry. + +**Acceptance Scenarios**: + +1. **Given** a directory containing a junction `Projects → C:\Dev\Projects`, **When** the user runs `rcdir`, **Then** the junction entry shows the filename, a `→` arrow, and the target path `C:\Dev\Projects` +2. **Given** a directory containing a file symlink `config.yml → ..\shared\config.yml`, **When** the user runs `rcdir`, **Then** the symlink entry shows the filename, a `→` arrow, and the target path as stored (`..\shared\config.yml`) +3. **Given** a directory containing a directory symlink `docs → D:\Documentation`, **When** the user runs `rcdir`, **Then** the symlink entry shows the filename, a `→` arrow, and the target path `D:\Documentation` +4. **Given** a directory with no junctions or symlinks, **When** the user runs `rcdir`, **Then** output is identical to current behavior — no arrows, no changes + +### User Story 2 — Tree Mode Target Display (Priority: P2) + +A user runs `rcdir --Tree` in a directory tree that contains junctions and symlinks. Each link entry in the tree shows the `→ target` suffix, just like in normal mode. Junctions and symlinks are displayed but not recursed into (existing behavior preserved). + +**Why this priority**: Tree mode is a heavily used display mode and already has partial reparse-point awareness (cycle guard). Extending it with target display is a natural complement. + +**Independent Test**: Create a directory tree with a junction at depth 2. Run `rcdir --Tree` and verify the junction shows its target and is not expanded. + +**Acceptance Scenarios**: + +1. **Given** a tree containing a junction `node_modules → C:\cache\node_modules`, **When** the user runs `rcdir --Tree`, **Then** the junction shows `→ C:\cache\node_modules` and its subtree is not expanded +2. **Given** a tree containing a file symlink, **When** the user runs `rcdir --Tree`, **Then** the file symlink shows `→ target` with the stored path +3. **Given** a tree with nested junctions (junction inside a regular directory), **When** the user runs `rcdir --Tree --Depth=3`, **Then** each junction at every level shows its target path + +### User Story 3 — Color-Coded Target Paths (Priority: P3) + +Link target paths are color-coded so users can distinguish the arrow from the target name. The arrow uses the existing `Information` color attribute. The target path uses the same color as the source filename (the link entry's resolved color attribute). + +**Why this priority**: Color coding enhances readability and keeps the output consistent with RCDir's existing color conventions. However, the feature is functional without it. + +**Independent Test**: Create a junction pointing to a directory and a file symlink pointing to a `.cpp` file. Run `rcdir` and verify the arrow color differs from the target path color, and that the target colors match existing directory/extension colors. + +**Acceptance Scenarios**: + +1. **Given** a junction `build → C:\Output\build`, **When** the user runs `rcdir`, **Then** the `→` arrow uses the `Information` color attribute and the target path `C:\Output\build` uses the same color as the junction entry name +2. **Given** a file symlink `main.cpp → ..\src\main.cpp`, **When** the user runs `rcdir`, **Then** the `→` arrow uses the `Information` color attribute and the target path uses the same color as the symlink entry name +3. **Given** a file symlink to a file with no recognized extension, **When** the user runs `rcdir`, **Then** the target path uses the same color as the symlink entry name +4. **Given** an AppExecLink entry (e.g., `python.exe` in `WindowsApps`), **When** the user runs `rcdir`, **Then** the `→` arrow uses the `Information` color attribute and the target path uses the same color as the AppExecLink entry name + +### User Story 4 — Internal Path Prefix Stripping (Priority: P3) + +Junction target paths stored internally with the `\??\` device prefix are displayed with that prefix stripped, showing clean user-readable paths. + +**Why this priority**: Cosmetic but important for usability — raw device paths are confusing to users. + +**Independent Test**: Create a junction (which stores targets with `\??\` internally). Run `rcdir` and verify the target path does not begin with `\??\`. + +**Acceptance Scenarios**: + +1. **Given** a junction whose internal target is `\??\C:\Users\Dev\Projects`, **When** the user runs `rcdir`, **Then** the displayed target is `C:\Users\Dev\Projects` + +### Edge Cases + +- **Access denied on link**: If the reparse data cannot be read (e.g., permission-restricted link), display the filename without a target — graceful degradation, no error shown +- **Very long target paths**: Target paths near MAX_PATH or using extended-length (`\\?\`) syntax are displayed in full (no truncation in this spec; future spec 008 — Ellipsize Long Targets — will add optional middle-truncation) +- **Recursive mode (`-S`)**: Junctions and symlinks show their targets but are not recursed into (existing behavior preserved) +- **Wide mode**: No target display — wide mode is explicitly out of scope +- **Bare mode**: No target display — bare mode output stays clean for scripting +- **File vs directory symlinks**: Both are supported; icons already distinguish the type +- **Non-reparse files**: Regular files and directories are completely unaffected +- **AppExecLink entries**: Store app aliases (e.g., `python.exe` in `WindowsApps`) show the resolved target executable path +- **Other reparse tags** (WCI, DEDUP, AF_UNIX, LX_SYMLINK, LX_FIFO): Silently ignored — no arrow, no indicator. Cloud reparse points are already handled by existing cloud status display + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: System MUST display `→ target_path` after the filename for all junctions in normal mode and tree mode +- **FR-002**: System MUST display `→ target_path` after the filename for all symlinks (file and directory) in normal mode and tree mode +- **FR-002a**: System MUST display `→ target_exe_path` after the filename for AppExecLink reparse points (`IO_REPARSE_TAG_APPEXECLINK`) in normal mode and tree mode +- **FR-003**: System MUST use the Unicode arrow character `→` (U+2192) as the link indicator, formatted as `filename → target` with exactly one space before and after the arrow +- **FR-004**: System MUST display target paths as-stored in the reparse data (relative paths stay relative, absolute paths stay absolute) +- **FR-005**: System MUST strip the `\??\` device prefix from junction target paths and absolute symlink SubstituteName fallback paths before display +- **FR-006**: System MUST render the `→` arrow using the existing `Information` color attribute +- **FR-007**: System MUST render the target path using the same color as the source filename (the link entry's resolved color attribute) +- **FR-009**: System MUST NOT display target paths in wide mode or bare mode +- **FR-010**: System MUST NOT recurse into junctions or symlinks (preserve existing reparse-point cycle guard behavior) +- **FR-011**: System MUST gracefully handle cases where reparse data cannot be read — display the filename without a target, no error message +- **FR-012**: System MUST NOT introduce any new command-line switches or configuration keys for this feature +- **FR-013**: System MUST NOT resolve or display hardlink information +- **FR-014**: System MUST silently ignore all reparse tags other than `IO_REPARSE_TAG_MOUNT_POINT`, `IO_REPARSE_TAG_SYMLINK`, and `IO_REPARSE_TAG_APPEXECLINK` — no arrow, no indicator, no error + +### Key Entities + +- **Reparse Point**: A file system entry with the `FILE_ATTRIBUTE_REPARSE_POINT` attribute set and a reparse tag indicating junction (`IO_REPARSE_TAG_MOUNT_POINT`), symlink (`IO_REPARSE_TAG_SYMLINK`), or AppExecLink (`IO_REPARSE_TAG_APPEXECLINK`) +- **Target Path**: The destination path stored in the reparse data buffer — may be relative or absolute, may contain the `\??\` device prefix for junctions +- **AppExecLink**: A Windows Store app execution alias — a 0-byte placeholder file that redirects execution to a Store app's real executable. The reparse buffer contains the target executable path, package family name, and app user model ID + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Users can identify where every junction and symlink points by reading a single `rcdir` listing — no need to run secondary commands +- **SC-002**: Target paths are displayed correctly for 100% of accessible junctions and symlinks in a directory +- **SC-003**: Listing performance in directories with no reparse points is unchanged — zero overhead when no links are present +- **SC-004**: Listing performance in directories with reparse points adds no user-perceptible delay (reparse data reads are per-link, not per-file) +- **SC-005**: All existing unit tests continue to pass with no modifications +- **SC-006**: New unit tests cover target resolution, path prefix stripping, color selection, and graceful error handling + +## Assumptions + +- The `→` (U+2192) character renders correctly in all target terminal environments (Windows Terminal, ConHost, etc.) — this is consistent with existing Unicode symbol usage in RCDir +- Reparse points are uncommon in typical directories (0–5 per listing), so the per-link cost of reading reparse data is negligible +- Hardlink display is explicitly out of scope and may be addressed in a future feature with an opt-in switch +- Wide mode and bare mode exclusion is a permanent design decision, not a deferral +- This feature does not require changes to the config file schema + +## Release Checklist + +The following items MUST be completed before this feature is considered shipped: + +- Bump the minor version number +- Update `CHANGELOG.md` with the new version entry and feature description +- Update `README.md` "What's New" table with a new version row +- Update `README.md` feature comparison table if applicable (symlink target display column) +- Update `TCDir/specs/sync-status.md` with spec 007 RCDir status diff --git a/specs/007-symlink-junction-targets/tasks.md b/specs/007-symlink-junction-targets/tasks.md new file mode 100644 index 0000000..1d00ed6 --- /dev/null +++ b/specs/007-symlink-junction-targets/tasks.md @@ -0,0 +1,179 @@ +# Tasks: Symlink & Junction Target Display + +**Input**: Design documents from `/specs/007-symlink-junction-targets/` +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, quickstart.md + +**Tests**: Included — spec requires unit tests for buffer parsing (SC-006). + +**Organization**: Tasks grouped by user story. Each story is independently testable. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story (US1, US2, US3, US4) + +--- + +## Phase 1: Setup + +**Purpose**: Module scaffolding, dependencies, FileInfo field addition + +- [X] T001 Add `reparse_target: String` field to `FileInfo` struct in src/file_info.rs +- [X] T002 [P] Create `src/reparse_resolver.rs` module with reparse tag constants and module-level doc comment +- [X] T003 [P] Register `reparse_resolver` module in src/lib.rs +- [X] T004 Verify `windows` crate features include `Win32_Storage_FileSystem` and `Win32_System_IO` in Cargo.toml (add if missing) + +**Checkpoint**: New module exists, compiles, FileInfo has reparse_target field + +--- + +## Phase 2: Foundational (Buffer Parsing — Pure Functions) + +**Purpose**: Implement all reparse buffer parsing as pure functions with no I/O. These are the testable core that all user stories depend on. + +**⚠️ CRITICAL**: No display or integration work can begin until parsers are complete and tested. + +- [X] T005 Implement `strip_device_prefix(path: &str) -> String` in src/reparse_resolver.rs — strips `\??\` prefix from paths (FR-005) +- [X] T006 [P] Define `REPARSE_DATA_BUFFER` header struct and mount point / symlink / AppExecLink sub-structures as `#[repr(C)]` types in src/reparse_resolver.rs (per data-model.md) +- [X] T007 Implement `parse_junction_buffer(buffer: &[u8]) -> String` in src/reparse_resolver.rs — extract PrintName (preferred) or SubstituteName with prefix stripping (FR-001, FR-004, FR-005) +- [X] T008 Implement `parse_symlink_buffer(buffer: &[u8]) -> String` in src/reparse_resolver.rs — extract PrintName (preferred) or SubstituteName; strip prefix only for absolute symlinks (FR-002, FR-004) +- [X] T009 Implement `parse_app_exec_link_buffer(buffer: &[u8]) -> String` in src/reparse_resolver.rs — parse version-3 buffer, extract third NUL-terminated UTF-16 string (FR-002a) +- [X] T010 [P] Add unit tests for `strip_device_prefix` in src/reparse_resolver.rs — prefix removal, UNC paths, empty strings, no-prefix paths (SC-006) +- [X] T011 Add test helper `build_junction_buffer(print_name, substitute_name) -> Vec` in src/reparse_resolver.rs tests +- [X] T012 Add unit tests for `parse_junction_buffer` in src/reparse_resolver.rs — PrintName extraction, SubstituteName fallback, prefix stripping, truncated buffer, empty names (SC-006) +- [X] T013 Add test helper `build_symlink_buffer(print_name, substitute_name, flags) -> Vec` in src/reparse_resolver.rs tests +- [X] T014 Add unit tests for `parse_symlink_buffer` in src/reparse_resolver.rs — absolute symlinks, relative symlinks (SYMLINK_FLAG_RELATIVE), conditional prefix stripping, truncated buffer, verify relative paths preserved as-stored without resolution (FR-004, SC-006) +- [X] T015 Add test helper `build_app_exec_link_buffer(version, pkg_id, app_id, target_exe) -> Vec` in src/reparse_resolver.rs tests +- [X] T016 Add unit tests for `parse_app_exec_link_buffer` in src/reparse_resolver.rs — version 3 parsing, version mismatch returns empty, truncated buffer, bounds checks (SC-006) + +**Checkpoint**: All three parsers + strip_device_prefix implemented and tested with synthetic byte arrays. `cargo test` passes. + +--- + +## Phase 3: User Story 1 — Normal Mode Target Display (Priority: P1) 🎯 MVP + +**Goal**: Show `→ target` after symlinks, junctions, and AppExecLinks in normal mode listings. + +**Independent Test**: Run `rcdir` in a directory with a junction and verify `→ target` appears. + +### Implementation for User Story 1 + +- [X] T017 [US1] Implement `resolve_reparse_target(dir_path: &Path, file_info: &FileInfo) -> String` in src/reparse_resolver.rs — Win32 I/O wrapper: check attribute flag, check tag, build full path from `dir_path` + `file_info.file_name`, CreateFileW + DeviceIoControl, dispatch to parser (FR-001, FR-002, FR-002a, FR-011, FR-014) +- [X] T018 [US1] Call `resolve_reparse_target()` in `add_match_to_list()` in src/directory_lister.rs — store result in `file_info.reparse_target` (Research Decision 2) +- [X] T019 [US1] Call `resolve_reparse_target()` in multi-threaded enumeration path in src/multi_threaded_lister.rs — same integration as T018 +- [X] T020 [US1] Append `→ target` display in src/results_displayer/normal.rs — if `reparse_target` is non-empty: print ` → ` with Information color, then target with filename color (FR-003, FR-006, FR-007, FR-009) +- [X] T021 [US1] Verify wide mode and bare mode do NOT display targets in src/results_displayer/wide.rs and src/results_displayer/bare.rs — confirm no changes needed (FR-009) + +**Checkpoint**: Normal mode shows `→ target` for junctions, symlinks, and AppExecLinks. Wide/bare modes unaffected. `cargo test` passes. + +--- + +## Phase 4: User Story 2 — Tree Mode Target Display (Priority: P2) + +**Goal**: Show `→ target` after symlinks and junctions in tree mode listings. + +**Independent Test**: Run `rcdir --Tree` in a directory tree with a junction and verify `→ target` appears. + +### Implementation for User Story 2 + +- [X] T022 [US2] Append `→ target` display in src/results_displayer/tree.rs — same pattern as T020: if `reparse_target` non-empty, print arrow with Information color and target with filename color (FR-001, FR-002, FR-003, FR-006, FR-007) +- [X] T023 [US2] Verify junctions and symlinks are not recursed into during tree walk — confirm existing reparse-point cycle guard behavior is preserved (FR-010) + +**Checkpoint**: Tree mode shows `→ target` for links. Junctions/symlinks not expanded. `cargo test` passes. + +--- + +## Phase 5: User Story 3 — Color-Coded Target Paths (Priority: P3) + +**Goal**: Arrow uses Information color, target uses filename's resolved color. + +**Independent Test**: Run `rcdir` on a junction and a file symlink; verify arrow color differs from target color. + +### Implementation for User Story 3 + +- [X] T024 [US3] Verify color implementation in normal.rs (T020) and tree.rs (T022) already uses correct attributes — Information for arrow, `text_attr` for target (FR-006, FR-007) +- [X] T025 [P] [US3] Add unit test verifying Information color ANSI escape sequence appears before arrow character in mock console buffered output (SC-006) + +**Checkpoint**: Colors verified correct. If T020/T022 already implemented colors correctly, this phase is validation only. + +--- + +## Phase 6: User Story 4 — Internal Path Prefix Stripping (Priority: P3) + +**Goal**: `\??\` device prefix stripped from junction targets. + +**Independent Test**: Create a junction; verify displayed target doesn't start with `\??\`. + +### Implementation for User Story 4 + +- [X] T026 [US4] Verify `strip_device_prefix` is already called in `parse_junction_buffer` (T007) and `parse_symlink_buffer` (T008) for SubstituteName fallback — confirm FR-005 is satisfied (T012 already covers this test case) + +**Checkpoint**: Prefix stripping verified. If T007/T008 already handle this, phase is validation only. + +--- + +## Phase 7: Polish & Cross-Cutting Concerns + +**Purpose**: Final verification, edge cases, cleanup + +- [X] T028 Run `cargo clippy -- -D warnings` and fix any warnings +- [X] T029 Run `cargo test` and verify all existing + new tests pass (SC-005) +- [X] T030 Verify recursive mode (`-S`) shows targets but does not recurse into links (FR-010) +- [X] T031 Verify access-denied edge case: unreadable reparse point displays filename without target, no error (FR-011) +- [X] T032 Verify non-reparse files are completely unaffected — no performance regression (SC-003) +- [X] T033 Verify no new command-line switches or config keys were introduced (FR-012) +- [X] T034 Verify hardlink information is not resolved or displayed (FR-013) +- [X] T035 Add output parity test in tests/output_parity.rs — normal mode listing of directory with junctions/symlinks, compare rcdir vs tcdir output +- [X] T036 Add output parity test in tests/output_parity.rs — tree mode listing of directory with junctions/symlinks, compare rcdir vs tcdir output +- [X] T037 Add output parity test in tests/output_parity.rs — AppExecLink display (e.g., WindowsApps directory), compare rcdir vs tcdir output + +**Checkpoint**: Feature complete, all tests pass, clippy clean. + +--- + +## Dependencies + +``` +Phase 1 (Setup) → Phase 2 (Parsers) → Phase 3 (US1: Normal) → Phase 4 (US2: Tree) + ↓ + Phase 5 (US3: Colors) — validation only + Phase 6 (US4: Prefix) — validation only + ↓ + Phase 7 (Polish) +``` + +- Phase 3 (US1) depends on Phase 2 completion +- Phase 4 (US2) depends on Phase 3 (reuses same resolver + display pattern) +- Phases 5–6 are validation of work already done in Phases 2–4 +- Phase 7 depends on all prior phases + +## Parallel Execution Opportunities + +| Tasks | Why Parallel | +|-------|-------------| +| T002, T003 | Different files (reparse_resolver.rs vs lib.rs) | +| T006, T010 | Struct definitions vs strip_device_prefix tests (no overlap) | +| T011, T013, T015 | Test helpers for different buffer types (independent) | +| T024, T025 | Color validation vs color unit test (independent) | + +## Implementation Strategy + +- **MVP**: Phase 1 + Phase 2 + Phase 3 = normal mode shows targets (core feature) +- **Increment 1**: Phase 4 = tree mode +- **Increment 2**: Phases 5–6 = color and prefix validation +- **Ship**: Phase 7 = polish and verify + +## Summary + +| Metric | Value | +|--------|-------| +| Total tasks | 37 | +| Phase 1 (Setup) | 4 tasks | +| Phase 2 (Foundational) | 12 tasks | +| Phase 3 (US1: Normal) | 5 tasks | +| Phase 4 (US2: Tree) | 2 tasks | +| Phase 5 (US3: Colors) | 2 tasks | +| Phase 6 (US4: Prefix) | 1 task | +| Phase 7 (Polish) | 10 tasks | +| Parallel opportunities | 4 groups | +| MVP scope | Phases 1–3 (21 tasks) | diff --git a/src/config/mod.rs b/src/config/mod.rs index 99e2116..05db47f 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -2035,6 +2035,7 @@ mod tests { last_write_time: 0, last_access_time: 0, reparse_tag: 0, + reparse_target: String::new(), streams: Vec::new(), }; let style = cfg.get_display_style_for_file (&fi_git); @@ -2050,6 +2051,7 @@ mod tests { last_write_time: 0, last_access_time: 0, reparse_tag: 0, + reparse_target: String::new(), streams: Vec::new(), }; let style_upper = cfg.get_display_style_for_file (&fi_git_upper); @@ -2084,6 +2086,7 @@ mod tests { last_write_time: 0, last_access_time: 0, reparse_tag: 0, + reparse_target: String::new(), streams: Vec::new(), }; let style = cfg.get_display_style_for_file (&fi_git); @@ -3258,6 +3261,7 @@ mod tests { last_write_time: 0, last_access_time: 0, reparse_tag: 0, + reparse_target: String::new(), streams: Vec::new(), }; @@ -3290,6 +3294,7 @@ mod tests { last_write_time: 0, last_access_time: 0, reparse_tag: 0, + reparse_target: String::new(), streams: Vec::new(), }; @@ -3322,6 +3327,7 @@ mod tests { last_write_time: 0, last_access_time: 0, reparse_tag: 0, + reparse_target: String::new(), streams: Vec::new(), }; @@ -3355,6 +3361,7 @@ mod tests { last_write_time: 0, last_access_time: 0, reparse_tag: 0, + reparse_target: String::new(), streams: Vec::new(), }; @@ -3387,6 +3394,7 @@ mod tests { last_write_time: 0, last_access_time: 0, reparse_tag: IO_REPARSE_TAG_SYMLINK, + reparse_target: String::new(), streams: Vec::new(), }; @@ -3419,6 +3427,7 @@ mod tests { last_write_time: 0, last_access_time: 0, reparse_tag: IO_REPARSE_TAG_MOUNT_POINT, + reparse_target: String::new(), streams: Vec::new(), }; @@ -3451,6 +3460,7 @@ mod tests { last_write_time: 0, last_access_time: 0, reparse_tag: 0, + reparse_target: String::new(), streams: Vec::new(), }; @@ -3486,6 +3496,7 @@ mod tests { last_write_time: 0, last_access_time: 0, reparse_tag: 0, + reparse_target: String::new(), streams: Vec::new(), }; diff --git a/src/directory_lister.rs b/src/directory_lister.rs index 0a79d52..de2a92c 100644 --- a/src/directory_lister.rs +++ b/src/directory_lister.rs @@ -113,7 +113,10 @@ fn add_match_to_list( totals: &mut ListingTotals, cmd: &CommandLine, ) { - let file_entry = FileInfo::from_find_data(wfd); + let mut file_entry = FileInfo::from_find_data(wfd); + + // Resolve reparse target (symlink/junction/AppExecLink) — empty string if not applicable + file_entry.reparse_target = crate::reparse_resolver::resolve_reparse_target (&di.dir_path, &file_entry); // Track filename length for wide listing let file_name_len = if cmd.wide_listing { diff --git a/src/file_comparator.rs b/src/file_comparator.rs index fc7ccc0..09cd868 100644 --- a/src/file_comparator.rs +++ b/src/file_comparator.rs @@ -262,6 +262,7 @@ mod tests { last_write_time: 0, last_access_time: 0, reparse_tag: 0, + reparse_target: String::new(), streams: Vec::new(), } } diff --git a/src/file_info.rs b/src/file_info.rs index fcd27b5..ec9ce29 100644 --- a/src/file_info.rs +++ b/src/file_info.rs @@ -143,6 +143,7 @@ pub struct FileInfo { pub last_write_time: u64, // FILETIME as u64 pub last_access_time: u64, // FILETIME as u64 pub reparse_tag: u32, // dwReserved0 — reparse tag for cloud/symlink detection + pub reparse_target: String, // Resolved symlink/junction target path (empty if not applicable) pub streams: Vec, } @@ -192,6 +193,7 @@ impl FileInfo { last_write_time, last_access_time, reparse_tag: wfd.dwReserved0, + reparse_target: String::new(), streams: Vec::new(), } } @@ -342,6 +344,7 @@ mod tests { last_write_time: 0, last_access_time: 0, reparse_tag: 0, + reparse_target: String::new(), streams: Vec::new(), }; assert!(fi.is_directory()); @@ -369,6 +372,7 @@ mod tests { last_write_time: 0, last_access_time: 0, reparse_tag: 0, + reparse_target: String::new(), streams: Vec::new(), }; assert!(fi.is_dot_dir()); diff --git a/src/lib.rs b/src/lib.rs index 4b5dd32..59602ac 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,6 +21,7 @@ pub mod work_queue; pub mod results_displayer; pub mod cloud_status; pub mod streams; +pub mod reparse_resolver; pub mod owner; pub mod usage; pub mod icon_mapping; diff --git a/src/multi_threaded_lister.rs b/src/multi_threaded_lister.rs index 8635306..639399e 100644 --- a/src/multi_threaded_lister.rs +++ b/src/multi_threaded_lister.rs @@ -780,7 +780,9 @@ fn enumerate_matching_files( let excluded_ok = (attrs & cmd.attrs_excluded) == 0; if required_ok && excluded_ok { - let file_entry = FileInfo::from_find_data(&wfd); + let mut file_entry = FileInfo::from_find_data(&wfd); + let dir_path = { node.0.lock().unwrap().dir_path.clone() }; + file_entry.reparse_target = crate::reparse_resolver::resolve_reparse_target (&dir_path, &file_entry); let mut di = node.0.lock().unwrap(); add_match_to_list(&wfd, file_entry, &mut di, cmd); } @@ -890,7 +892,9 @@ fn enumerate_subdirectories( if !seen_dirs.contains (&lower_name) { seen_dirs.insert (lower_name); - let file_entry = FileInfo::from_find_data (&wfd); + let mut file_entry = FileInfo::from_find_data (&wfd); + let dir_path = { node.0.lock().unwrap().dir_path.clone() }; + file_entry.reparse_target = crate::reparse_resolver::resolve_reparse_target (&dir_path, &file_entry); let mut di = node.0.lock().unwrap(); add_match_to_list (&wfd, file_entry, &mut di, cmd); } diff --git a/src/reparse_resolver.rs b/src/reparse_resolver.rs new file mode 100644 index 0000000..b7d288f --- /dev/null +++ b/src/reparse_resolver.rs @@ -0,0 +1,717 @@ +// reparse_resolver.rs — Symlink, junction, and AppExecLink target resolution +// +// Reads reparse data via Win32 DeviceIoControl(FSCTL_GET_REPARSE_POINT) +// and parses the three supported buffer formats. Pure parsing functions +// are separated from I/O for testability. + +use std::ffi::OsStr; +use std::os::windows::ffi::OsStrExt; +use std::path::Path; + +use windows::Win32::Storage::FileSystem::{ + CreateFileW, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, + FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, +}; +use windows::Win32::System::IO::DeviceIoControl; + +use crate::file_info::FileInfo; + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// Constants +// +//////////////////////////////////////////////////////////////////////////////// + +/// Junction / mount point reparse tag. +pub const IO_REPARSE_TAG_MOUNT_POINT: u32 = 0xA000_0003; + +/// Symbolic link reparse tag (file or directory). +pub const IO_REPARSE_TAG_SYMLINK: u32 = 0xA000_000C; + +/// Windows Store app execution alias reparse tag. +pub const IO_REPARSE_TAG_APPEXECLINK: u32 = 0x8000_001B; + +/// Flag in the symlink reparse buffer indicating a relative symlink. +const SYMLINK_FLAG_RELATIVE: u32 = 0x0000_0001; + +/// Maximum reparse data buffer size (16 KB, per NTFS specification). +const MAXIMUM_REPARSE_DATA_BUFFER_SIZE: usize = 16_384; + +/// File attribute flag for reparse points. +const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + +/// FSCTL code to read reparse point data. +const FSCTL_GET_REPARSE_POINT: u32 = 0x000900A8; + +/// Device prefix that junctions store internally. +const DEVICE_PREFIX: &str = "\\??\\"; + +/// Size of the reparse data buffer header (tag + data_length + reserved). +const REPARSE_HEADER_SIZE: usize = 8; + +/// Size of the mount-point sub-header (4 u16 fields = 8 bytes). +const MOUNT_POINT_HEADER_SIZE: usize = 8; + +/// Size of the symlink sub-header (4 u16 fields + 1 u32 flags = 12 bytes). +const SYMLINK_HEADER_SIZE: usize = 12; + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// strip_device_prefix +// +// Remove the \??\ NT device prefix from a path string. +// Returns the input unchanged if the prefix is not present. +// +//////////////////////////////////////////////////////////////////////////////// + +pub fn strip_device_prefix (path: &str) -> String { + if let Some (stripped) = path.strip_prefix (DEVICE_PREFIX) { + stripped.to_string() + } else { + path.to_string() + } +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// parse_junction_buffer +// +// Parse an IO_REPARSE_TAG_MOUNT_POINT reparse data buffer. +// Prefers PrintName; falls back to SubstituteName with \??\ stripping. +// Returns empty string on any parse failure. +// +//////////////////////////////////////////////////////////////////////////////// + +pub fn parse_junction_buffer (buffer: &[u8]) -> String { + // Validate minimum header size + if buffer.len() < REPARSE_HEADER_SIZE + MOUNT_POINT_HEADER_SIZE { + return String::new(); + } + + // Verify reparse tag + let tag = u32::from_le_bytes ([buffer[0], buffer[1], buffer[2], buffer[3]]); + if tag != IO_REPARSE_TAG_MOUNT_POINT { + return String::new(); + } + + // Parse mount-point sub-header (starts at offset 8, after the main header) + let hdr = &buffer[REPARSE_HEADER_SIZE..]; + let substitute_name_offset = u16::from_le_bytes ([hdr[0], hdr[1]]) as usize; + let substitute_name_length = u16::from_le_bytes ([hdr[2], hdr[3]]) as usize; + let print_name_offset = u16::from_le_bytes ([hdr[4], hdr[5]]) as usize; + let print_name_length = u16::from_le_bytes ([hdr[6], hdr[7]]) as usize; + + // PathBuffer starts after header + sub-header + let path_buffer_offset = REPARSE_HEADER_SIZE + MOUNT_POINT_HEADER_SIZE; + + // Prefer PrintName (user-friendly, no device prefix) + if print_name_length > 0 + && let Some (name) = extract_utf16_string (buffer, path_buffer_offset + print_name_offset, print_name_length) + { + return name; + } + + // Fall back to SubstituteName with prefix stripping + if substitute_name_length > 0 + && let Some (name) = extract_utf16_string (buffer, path_buffer_offset + substitute_name_offset, substitute_name_length) + { + return strip_device_prefix (&name); + } + + String::new() +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// parse_symlink_buffer +// +// Parse an IO_REPARSE_TAG_SYMLINK reparse data buffer. +// Prefers PrintName; falls back to SubstituteName. +// Strips \??\ only for absolute symlinks (not SYMLINK_FLAG_RELATIVE). +// Returns empty string on any parse failure. +// +//////////////////////////////////////////////////////////////////////////////// + +pub fn parse_symlink_buffer (buffer: &[u8]) -> String { + // Validate minimum header size + if buffer.len() < REPARSE_HEADER_SIZE + SYMLINK_HEADER_SIZE { + return String::new(); + } + + // Verify reparse tag + let tag = u32::from_le_bytes ([buffer[0], buffer[1], buffer[2], buffer[3]]); + if tag != IO_REPARSE_TAG_SYMLINK { + return String::new(); + } + + // Parse symlink sub-header (starts at offset 8) + let hdr = &buffer[REPARSE_HEADER_SIZE..]; + let substitute_name_offset = u16::from_le_bytes ([hdr[0], hdr[1]]) as usize; + let substitute_name_length = u16::from_le_bytes ([hdr[2], hdr[3]]) as usize; + let print_name_offset = u16::from_le_bytes ([hdr[4], hdr[5]]) as usize; + let print_name_length = u16::from_le_bytes ([hdr[6], hdr[7]]) as usize; + let flags = u32::from_le_bytes ([hdr[8], hdr[9], hdr[10], hdr[11]]); + + // PathBuffer starts after header + symlink sub-header + let path_buffer_offset = REPARSE_HEADER_SIZE + SYMLINK_HEADER_SIZE; + + // Prefer PrintName + if print_name_length > 0 + && let Some (name) = extract_utf16_string (buffer, path_buffer_offset + print_name_offset, print_name_length) + { + return name; + } + + // Fall back to SubstituteName + if substitute_name_length > 0 + && let Some (name) = extract_utf16_string (buffer, path_buffer_offset + substitute_name_offset, substitute_name_length) + { + // Strip device prefix only for absolute symlinks + if (flags & SYMLINK_FLAG_RELATIVE) == 0 { + return strip_device_prefix (&name); + } + return name; + } + + String::new() +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// parse_app_exec_link_buffer +// +// Parse an IO_REPARSE_TAG_APPEXECLINK reparse data buffer. +// The buffer contains a version u32 (must be 3) followed by three +// NUL-terminated UTF-16 strings. The third string is the target +// executable path. +// Returns empty string on any parse failure. +// +//////////////////////////////////////////////////////////////////////////////// + +pub fn parse_app_exec_link_buffer (buffer: &[u8]) -> String { + // Validate minimum header size + if buffer.len() < REPARSE_HEADER_SIZE { + return String::new(); + } + + // Verify reparse tag + let tag = u32::from_le_bytes ([buffer[0], buffer[1], buffer[2], buffer[3]]); + if tag != IO_REPARSE_TAG_APPEXECLINK { + return String::new(); + } + + // The generic data starts right after the 8-byte header + let data = &buffer[REPARSE_HEADER_SIZE..]; + + // Need at least 4 bytes for the version field + if data.len() < 4 { + return String::new(); + } + + let version = u32::from_le_bytes ([data[0], data[1], data[2], data[3]]); + if version != 3 { + return String::new(); + } + + // Walk three NUL-terminated UTF-16 strings after the version field + let mut offset = 4; // skip version u32 + let mut string_index = 0; + + while string_index < 3 && offset + 1 < data.len() { + // Find NUL terminator (u16 == 0) + let start = offset; + while offset + 1 < data.len() { + let ch = u16::from_le_bytes ([data[offset], data[offset + 1]]); + offset += 2; + if ch == 0 { + break; + } + } + + if string_index == 2 { + // Third string = target exe path + let str_data = &data[start..offset.saturating_sub (2)]; // exclude NUL + if str_data.len() >= 2 { + let u16_chars: Vec = str_data + .chunks_exact (2) + .map (|pair| u16::from_le_bytes ([pair[0], pair[1]])) + .collect(); + return String::from_utf16_lossy (&u16_chars); + } + return String::new(); + } + + string_index += 1; + } + + String::new() +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// resolve_reparse_target +// +// Read the reparse data for a file and resolve its target path. +// Returns empty string if: +// - The file is not a reparse point +// - The reparse tag is not supported (junction, symlink, AppExecLink) +// - The file cannot be opened or the IOCTL fails +// - The buffer cannot be parsed +// +//////////////////////////////////////////////////////////////////////////////// + +pub fn resolve_reparse_target (dir_path: &Path, file_info: &FileInfo) -> String { + // Early exit: not a reparse point + if (file_info.file_attributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0 { + return String::new(); + } + + // Early exit: unsupported reparse tag + let tag = file_info.reparse_tag; + if tag != IO_REPARSE_TAG_MOUNT_POINT + && tag != IO_REPARSE_TAG_SYMLINK + && tag != IO_REPARSE_TAG_APPEXECLINK + { + return String::new(); + } + + // Build full path: dir_path + filename + let full_path = dir_path.join (&file_info.file_name); + + // Convert to wide string for CreateFileW + let wide_path: Vec = OsStr::new (&full_path) + .encode_wide() + .chain (std::iter::once (0)) + .collect(); + + // Open the reparse point itself (not the target) + let handle = unsafe { + CreateFileW ( + windows::core::PCWSTR (wide_path.as_ptr()), + 0, // No access needed — just for FSCTL + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + None, + OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS, + None, + ) + }; + + let handle = match handle { + Ok (h) => h, + Err (_) => return String::new(), // Access denied or other error — graceful degradation + }; + + // Read reparse data via IOCTL + let mut buffer = [0u8; MAXIMUM_REPARSE_DATA_BUFFER_SIZE]; + let mut bytes_returned: u32 = 0; + + let success = unsafe { + DeviceIoControl ( + handle, + FSCTL_GET_REPARSE_POINT, + None, + 0, + Some (buffer.as_mut_ptr().cast()), + buffer.len() as u32, + Some (&mut bytes_returned), + None, + ) + }; + + // Close handle + let _ = unsafe { windows::Win32::Foundation::CloseHandle (handle) }; + + if success.is_err() { + return String::new(); + } + + let data = &buffer[..bytes_returned as usize]; + + // Dispatch to the appropriate parser + match tag { + IO_REPARSE_TAG_MOUNT_POINT => parse_junction_buffer (data), + IO_REPARSE_TAG_SYMLINK => parse_symlink_buffer (data), + IO_REPARSE_TAG_APPEXECLINK => parse_app_exec_link_buffer (data), + _ => String::new(), + } +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// extract_utf16_string +// +// Extract a UTF-16LE string from a byte buffer at the given offset and length. +// Returns None if the range is out of bounds. +// +//////////////////////////////////////////////////////////////////////////////// + +fn extract_utf16_string (buffer: &[u8], offset: usize, byte_length: usize) -> Option { + let end = offset + byte_length; + if end > buffer.len() || byte_length < 2 { + return None; + } + + let u16_chars: Vec = buffer[offset..end] + .chunks_exact (2) + .map (|pair| u16::from_le_bytes ([pair[0], pair[1]])) + .collect(); + + Some (String::from_utf16_lossy (&u16_chars)) +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// Unit tests +// +//////////////////////////////////////////////////////////////////////////////// + +#[cfg(test)] +mod tests { + use super::*; + + + + + + //////////////////////////////////////////////////////////////////////////// + // + // Test helpers — buffer builders + // + //////////////////////////////////////////////////////////////////////////// + + /// Build a junction (mount-point) reparse data buffer with the given + /// PrintName and SubstituteName. + fn build_junction_buffer (print_name: &str, substitute_name: &str) -> Vec { + let print_wide: Vec = print_name.encode_utf16().collect(); + let sub_wide: Vec = substitute_name.encode_utf16().collect(); + + let print_bytes = print_wide.len() * 2; + let sub_bytes = sub_wide.len() * 2; + + // PathBuffer: SubstituteName then PrintName + let sub_offset: u16 = 0; + let print_offset: u16 = sub_bytes as u16; + + let path_buffer_size = sub_bytes + print_bytes; + let data_length = (MOUNT_POINT_HEADER_SIZE + path_buffer_size) as u16; + + let mut buf = Vec::new(); + + // Header: tag (4) + data_length (2) + reserved (2) + buf.extend_from_slice (&IO_REPARSE_TAG_MOUNT_POINT.to_le_bytes()); + buf.extend_from_slice (&data_length.to_le_bytes()); + buf.extend_from_slice (&0u16.to_le_bytes()); // reserved + + // Mount-point sub-header + buf.extend_from_slice (&sub_offset.to_le_bytes()); + buf.extend_from_slice (&(sub_bytes as u16).to_le_bytes()); + buf.extend_from_slice (&print_offset.to_le_bytes()); + buf.extend_from_slice (&(print_bytes as u16).to_le_bytes()); + + // PathBuffer + for ch in &sub_wide { + buf.extend_from_slice (&ch.to_le_bytes()); + } + for ch in &print_wide { + buf.extend_from_slice (&ch.to_le_bytes()); + } + + buf + } + + + + + + /// Build a symlink reparse data buffer with the given PrintName, + /// SubstituteName, and flags. + fn build_symlink_buffer (print_name: &str, substitute_name: &str, flags: u32) -> Vec { + let print_wide: Vec = print_name.encode_utf16().collect(); + let sub_wide: Vec = substitute_name.encode_utf16().collect(); + + let print_bytes = print_wide.len() * 2; + let sub_bytes = sub_wide.len() * 2; + + let sub_offset: u16 = 0; + let print_offset: u16 = sub_bytes as u16; + + let path_buffer_size = sub_bytes + print_bytes; + let data_length = (SYMLINK_HEADER_SIZE + path_buffer_size) as u16; + + let mut buf = Vec::new(); + + // Header + buf.extend_from_slice (&IO_REPARSE_TAG_SYMLINK.to_le_bytes()); + buf.extend_from_slice (&data_length.to_le_bytes()); + buf.extend_from_slice (&0u16.to_le_bytes()); + + // Symlink sub-header + buf.extend_from_slice (&sub_offset.to_le_bytes()); + buf.extend_from_slice (&(sub_bytes as u16).to_le_bytes()); + buf.extend_from_slice (&print_offset.to_le_bytes()); + buf.extend_from_slice (&(print_bytes as u16).to_le_bytes()); + buf.extend_from_slice (&flags.to_le_bytes()); + + // PathBuffer + for ch in &sub_wide { + buf.extend_from_slice (&ch.to_le_bytes()); + } + for ch in &print_wide { + buf.extend_from_slice (&ch.to_le_bytes()); + } + + buf + } + + + + + + /// Build an AppExecLink reparse data buffer with the given version, + /// package ID, app user model ID, and target exe path. + fn build_app_exec_link_buffer (version: u32, pkg_id: &str, app_id: &str, target_exe: &str) -> Vec { + let mut buf = Vec::new(); + + // Header + buf.extend_from_slice (&IO_REPARSE_TAG_APPEXECLINK.to_le_bytes()); + + // Placeholder for data_length — filled in at the end + let data_len_pos = buf.len(); + buf.extend_from_slice (&0u16.to_le_bytes()); + buf.extend_from_slice (&0u16.to_le_bytes()); // reserved + + let data_start = buf.len(); + + // Version + buf.extend_from_slice (&version.to_le_bytes()); + + // Three NUL-terminated UTF-16 strings + for s in &[pkg_id, app_id, target_exe] { + for ch in s.encode_utf16() { + buf.extend_from_slice (&ch.to_le_bytes()); + } + buf.extend_from_slice (&0u16.to_le_bytes()); // NUL terminator + } + + // Patch data_length + let data_length = (buf.len() - data_start) as u16; + buf[data_len_pos..data_len_pos + 2].copy_from_slice (&data_length.to_le_bytes()); + + buf + } + + + + + + //////////////////////////////////////////////////////////////////////////// + // + // strip_device_prefix tests + // + //////////////////////////////////////////////////////////////////////////// + + #[test] + fn strip_prefix_removes_device_prefix() { + assert_eq! (strip_device_prefix ("\\??\\C:\\Users\\Dev"), "C:\\Users\\Dev"); + } + + #[test] + fn strip_prefix_preserves_no_prefix_path() { + assert_eq! (strip_device_prefix ("C:\\Users\\Dev"), "C:\\Users\\Dev"); + } + + #[test] + fn strip_prefix_preserves_unc_path() { + assert_eq! (strip_device_prefix ("\\\\server\\share"), "\\\\server\\share"); + } + + #[test] + fn strip_prefix_handles_empty_string() { + assert_eq! (strip_device_prefix (""), ""); + } + + #[test] + fn strip_prefix_handles_prefix_only() { + assert_eq! (strip_device_prefix ("\\??\\"), ""); + } + + + + + + //////////////////////////////////////////////////////////////////////////// + // + // parse_junction_buffer tests + // + //////////////////////////////////////////////////////////////////////////// + + #[test] + fn junction_extracts_print_name() { + let buf = build_junction_buffer ("C:\\Dev\\Projects", "\\??\\C:\\Dev\\Projects"); + assert_eq! (parse_junction_buffer (&buf), "C:\\Dev\\Projects"); + } + + #[test] + fn junction_falls_back_to_substitute_name_with_prefix_stripped() { + let buf = build_junction_buffer ("", "\\??\\C:\\Dev\\Projects"); + assert_eq! (parse_junction_buffer (&buf), "C:\\Dev\\Projects"); + } + + #[test] + fn junction_substitute_name_without_prefix() { + let buf = build_junction_buffer ("", "C:\\Dev\\Projects"); + assert_eq! (parse_junction_buffer (&buf), "C:\\Dev\\Projects"); + } + + #[test] + fn junction_empty_names_returns_empty() { + let buf = build_junction_buffer ("", ""); + assert_eq! (parse_junction_buffer (&buf), ""); + } + + #[test] + fn junction_truncated_buffer_returns_empty() { + assert_eq! (parse_junction_buffer (&[0u8; 4]), ""); + } + + #[test] + fn junction_wrong_tag_returns_empty() { + let mut buf = build_junction_buffer ("C:\\Test", "\\??\\C:\\Test"); + // Corrupt the tag + buf[0] = 0xFF; + assert_eq! (parse_junction_buffer (&buf), ""); + } + + + + + + //////////////////////////////////////////////////////////////////////////// + // + // parse_symlink_buffer tests + // + //////////////////////////////////////////////////////////////////////////// + + #[test] + fn symlink_extracts_print_name() { + let buf = build_symlink_buffer ("C:\\Target\\File.txt", "\\??\\C:\\Target\\File.txt", 0); + assert_eq! (parse_symlink_buffer (&buf), "C:\\Target\\File.txt"); + } + + #[test] + fn symlink_absolute_strips_prefix_from_substitute() { + let buf = build_symlink_buffer ("", "\\??\\C:\\Target\\File.txt", 0); + assert_eq! (parse_symlink_buffer (&buf), "C:\\Target\\File.txt"); + } + + #[test] + fn symlink_relative_preserves_substitute_as_stored() { + let buf = build_symlink_buffer ("", "..\\shared\\config.yml", SYMLINK_FLAG_RELATIVE); + assert_eq! (parse_symlink_buffer (&buf), "..\\shared\\config.yml"); + } + + #[test] + fn symlink_relative_with_print_name_uses_print_name() { + let buf = build_symlink_buffer ("..\\shared\\config.yml", "..\\shared\\config.yml", SYMLINK_FLAG_RELATIVE); + assert_eq! (parse_symlink_buffer (&buf), "..\\shared\\config.yml"); + } + + #[test] + fn symlink_truncated_buffer_returns_empty() { + assert_eq! (parse_symlink_buffer (&[0u8; 4]), ""); + } + + #[test] + fn symlink_wrong_tag_returns_empty() { + let mut buf = build_symlink_buffer ("C:\\Test", "", 0); + buf[0] = 0xFF; + assert_eq! (parse_symlink_buffer (&buf), ""); + } + + #[test] + fn symlink_empty_names_returns_empty() { + let buf = build_symlink_buffer ("", "", 0); + assert_eq! (parse_symlink_buffer (&buf), ""); + } + + + + + + //////////////////////////////////////////////////////////////////////////// + // + // parse_app_exec_link_buffer tests + // + //////////////////////////////////////////////////////////////////////////// + + #[test] + fn appexeclink_extracts_target_exe() { + let buf = build_app_exec_link_buffer ( + 3, + "Microsoft.DesktopAppInstaller_8wekyb3d8bbwe", + "Microsoft.DesktopAppInstaller_8wekyb3d8bbwe!winget", + "C:\\Program Files\\WindowsApps\\winget.exe", + ); + assert_eq! ( + parse_app_exec_link_buffer (&buf), + "C:\\Program Files\\WindowsApps\\winget.exe" + ); + } + + #[test] + fn appexeclink_version_mismatch_returns_empty() { + let buf = build_app_exec_link_buffer (2, "pkg", "app", "target.exe"); + assert_eq! (parse_app_exec_link_buffer (&buf), ""); + } + + #[test] + fn appexeclink_truncated_buffer_returns_empty() { + assert_eq! (parse_app_exec_link_buffer (&[0u8; 4]), ""); + } + + #[test] + fn appexeclink_wrong_tag_returns_empty() { + let mut buf = build_app_exec_link_buffer (3, "pkg", "app", "target.exe"); + buf[0] = 0xFF; + assert_eq! (parse_app_exec_link_buffer (&buf), ""); + } + + #[test] + fn appexeclink_empty_target_returns_empty() { + let buf = build_app_exec_link_buffer (3, "pkg", "app", ""); + assert_eq! (parse_app_exec_link_buffer (&buf), ""); + } +} diff --git a/src/results_displayer/normal.rs b/src/results_displayer/normal.rs index cdd9960..e0b4e9f 100644 --- a/src/results_displayer/normal.rs +++ b/src/results_displayer/normal.rs @@ -250,7 +250,15 @@ fn display_file_results( // Filename let name_str = file_info.file_name.to_string_lossy(); - console.writef_line (text_attr, format_args! ("{}", name_str)); + + if !file_info.reparse_target.is_empty() { + // Reparse point: filename → target (FR-003, FR-006, FR-007) + console.writef (text_attr, format_args! ("{}", name_str)); + console.printf (config.attributes[Attribute::Information as usize], " \u{2192} "); + console.writef_line (text_attr, format_args! ("{}", file_info.reparse_target)); + } else { + console.writef_line (text_attr, format_args! ("{}", name_str)); + } // Streams (if --streams and this is a file, not a directory) if cmd.show_streams && !file_info.streams.is_empty() { diff --git a/src/results_displayer/tree.rs b/src/results_displayer/tree.rs index 1ef1645..4519dce 100644 --- a/src/results_displayer/tree.rs +++ b/src/results_displayer/tree.rs @@ -351,7 +351,15 @@ impl TreeDisplayer { // Filename let name_str = file_info.file_name.to_string_lossy(); - console.writef_line (text_attr, format_args! ("{}", name_str)); + + if !file_info.reparse_target.is_empty() { + // Reparse point: filename → target (FR-003, FR-006, FR-007) + console.writef (text_attr, format_args! ("{}", name_str)); + console.printf (self.config.attributes[Attribute::Information as usize], " \u{2192} "); + console.writef_line (text_attr, format_args! ("{}", file_info.reparse_target)); + } else { + console.writef_line (text_attr, format_args! ("{}", name_str)); + } // Alternate data streams (if --streams and this entry has them) if self.cmd.show_streams && !file_info.streams.is_empty() { @@ -517,6 +525,7 @@ mod tests { last_write_time: 133_500_000_000_000_000, last_access_time: 133_500_000_000_000_000, reparse_tag: 0, + reparse_target: String::new(), streams: Vec::new(), } } @@ -531,6 +540,7 @@ mod tests { last_write_time: 133_500_000_000_000_000, last_access_time: 133_500_000_000_000_000, reparse_tag: 0, + reparse_target: String::new(), streams: Vec::new(), } } diff --git a/tests/output_parity.rs b/tests/output_parity.rs index a961408..3c6db57 100644 --- a/tests/output_parity.rs +++ b/tests/output_parity.rs @@ -1184,3 +1184,102 @@ fn parity_help_output() { ); } } + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// parity_reparse_normal_mode +// +// Verifies output parity for a directory containing junctions/symlinks +// in normal mode. Uses %USERPROFILE% which typically has Application Data +// junction and other reparse points. +// +//////////////////////////////////////////////////////////////////////////////// + +#[test] +fn parity_reparse_normal_mode() { + if let Ok (profile) = std::env::var ("USERPROFILE") { + let pattern = format! ("{}\\*", profile); + let (matching, total, diffs) = compare_output (&[&pattern]); + if total > 0 && !diffs.is_empty() && !diffs[0].contains ("not found") { + let pct = (matching as f64 / total as f64) * 100.0; + assert!( + pct >= 95.0, + "Reparse normal parity too low: {:.1}% ({}/{} lines). Diffs:\n{}", + pct, + matching, + total, + diffs.join ("\n"), + ); + } + } +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// parity_reparse_tree_mode +// +// Verifies output parity for a directory containing junctions/symlinks +// in tree mode with depth limiting. +// +//////////////////////////////////////////////////////////////////////////////// + +#[test] +fn parity_reparse_tree_mode() { + if let Ok (profile) = std::env::var ("USERPROFILE") { + let pattern = format! ("{}\\*", profile); + let (matching, total, diffs) = compare_output (&["/Tree", "/Depth=1", &pattern]); + if total > 0 && !diffs.is_empty() && !diffs[0].contains ("not found") { + let pct = (matching as f64 / total as f64) * 100.0; + assert!( + pct >= 95.0, + "Reparse tree parity too low: {:.1}% ({}/{} lines). Diffs:\n{}", + pct, + matching, + total, + diffs.join ("\n"), + ); + } + } +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// parity_reparse_appexeclink +// +// Verifies output parity for the WindowsApps directory which contains +// AppExecLink reparse points (python.exe, winget.exe, etc.). +// Skips if the directory does not exist. +// +//////////////////////////////////////////////////////////////////////////////// + +#[test] +fn parity_reparse_appexeclink() { + let apps_dir = r"C:\Users\relmer\AppData\Local\Microsoft\WindowsApps"; + if std::path::Path::new (apps_dir).exists() { + let pattern = format! ("{}\\*", apps_dir); + let (matching, total, diffs) = compare_output (&[&pattern]); + if total > 0 && !diffs.is_empty() && !diffs[0].contains ("not found") { + let pct = (matching as f64 / total as f64) * 100.0; + assert!( + pct >= 95.0, + "AppExecLink parity too low: {:.1}% ({}/{} lines). Diffs:\n{}", + pct, + matching, + total, + diffs.join ("\n"), + ); + } + } +}