Skip to content

rc mirror / rc object copy --recursive reject every object key containing : — Windows naming rules applied unconditionally #338

Description

@vroomfondel

Summary

rc mirror and rc object copy --recursive refuse to download any object whose key contains a colon, including on Linux, where : is a perfectly legal filename character. The rule is a Windows filename-portability check that runs unconditionally.

Because the check happens while the source is being enumerated, a single such key aborts the whole operation — not just that one object. --continue-on-error does not help.

Listing is unaffected: rc object list --recursive returns such keys without complaint, and a single object transfers fine when the destination filename is given explicitly. The restriction is purely in deriving a local path from a key.

The practical effect: a Grafana Loki bucket cannot be mirrored or recursively downloaded at all, so there is no rc-based backup or restore path for one.

Environment

  • rc 0.1.30, linux amd64 and arm64 (same behaviour on both)
  • server: RustFS 1.0.0-beta.11
  • the code is unchanged on main @ ef82a1d (2026-07-26)

Reproduction

Complete and self-contained; the only thing that matters is the colon in the last path segment.

$ rc bucket create local/rc-colon-repro-tmp
✓ Bucket 'local/rc-colon-repro-tmp' created successfully.

$ head -c 1024 /dev/urandom > /tmp/chunk
$ rc object copy /tmp/chunk "local/rc-colon-repro-tmp/fake/deadbeef/19f6abd9af4:19f6abe0e77:499628ff"
/tmp/chunk -> local/rc-colon-repro-tmp/fake/deadbeef/19f6abd9af4:19f6abe0e77:499628ff (1 KiB)

Uploading is fine — nothing derives a local path there. Listing is fine too:

$ rc object list --recursive local/rc-colon-repro-tmp
[2026-08-05 11:39:17]      1 KiB fake/deadbeef/19f6abd9af4:19f6abe0e77:499628ff
$ echo $?
0

Downloading is not:

$ rc mirror local/rc-colon-repro-tmp /tmp/out
✗ Failed to enumerate mirror source: Invalid path: Mirror path component is not portable: 19f6abd9af4:19f6abe0e77:499628ff
$ echo $?
2

$ rc object copy --recursive local/rc-colon-repro-tmp/fake/deadbeef /tmp/out2
✗ Failed to plan copy: Invalid path: colon characters are not allowed in download paths
$ echo $?
2

--continue-on-error makes no difference — its help text says "Continue after per-file failures", and this failure happens before the per-file phase:

$ rc mirror --continue-on-error --dry-run local/rc-colon-repro-tmp /tmp/out
✗ Failed to enumerate mirror source: Invalid path: Mirror path component is not portable: 19f6abd9af4:19f6abe0e77:499628ff
$ echo $?
2

The very same object transfers without complaint when the destination filename is supplied explicitly, which locates the restriction precisely — in deriving the local name, not in the transfer:

$ rc object copy "local/rc-colon-repro-tmp/fake/deadbeef/19f6abd9af4:19f6abe0e77:499628ff" /tmp/out2/single
local/rc-colon-repro-tmp/fake/deadbeef/19f6abd9af4:19f6abe0e77:499628ff -> /tmp/out2/single (1 KiB)
$ echo $?
0

Observed identically against a real ~9 GB Loki bucket with several thousand chunks, where it means the bucket cannot be backed up at all.

Root cause

crates/cli/src/commands/mirror.rs:1350:

fn validate_portable_component(component: &str) -> rc_core::Result<()> {
    if component.chars().any(|character| {
        character.is_control() || matches!(character, ':' | '<' | '>' | '"' | '|' | '?' | '*')
    }) || component.ends_with(['.', ' '])
    {
        return Err(Error::InvalidPath(format!(
            "Mirror path component is not portable: {component}"
        )));
    }
    // ... plus CON / PRN / AUX / NUL / COM1-9 / LPT1-9

That is the Windows reserved-character set plus the reserved device names, enforced on every platform.

It is reached from normalize_relative_path() (mirror.rs:1323), which is called during enumeration/planning (call sites mirror.rs:156, 985, 1393, 1406, 1638). The Err propagates up and surfaces as the enumeration failure at mirror.rs:690 — which is why one bad key kills the entire run rather than one object.

crates/cli/src/commands/cp.rs:2719 implements the same policy a second time in safe_download_relative_path(), with an explicit component.contains(':') at cp.rs:2734.

Why this matters

Colons are valid in S3 object keys, and at least one widely deployed producer puts them there by design: Loki chunk keys have the form

<tenant>/<fingerprint>/<start>:<through>:<checksum>

so every single chunk trips the rule. Any bucket written by Loki is therefore unreachable for rc mirror and rc object copy --recursive, on any platform.

Suggested fix

The traversal and absolute-path checks in these functions are security-relevant and should stay. The Windows naming rules are a separate concern and could be:

  1. gated on cfg!(windows) (or on the actual destination filesystem), and/or
  2. made opt-in via a flag such as --portable-names, or opt-out via --allow-unportable-names, and/or
  3. downgraded from "abort enumeration" to "skip this object, warn, exit non-zero" — which would also make --continue-on-error do what its help text promises.

An escaping mode (percent-encoding the offending characters in the local name) would be a further option, though it changes round-trip semantics and probably deserves its own flag.

Independently of which route you pick: mirror.rs and cp.rs currently carry two separate copies of this policy, with different error messages for the same rule. Sharing one function would keep them from drifting.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions