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:
- gated on
cfg!(windows) (or on the actual destination filesystem), and/or
- made opt-in via a flag such as
--portable-names, or opt-out via --allow-unportable-names, and/or
- 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.
Summary
rc mirrorandrc object copy --recursiverefuse 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-errordoes not help.Listing is unaffected:
rc object list --recursivereturns 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
rc0.1.30, linux amd64 and arm64 (same behaviour on both)main@ef82a1d(2026-07-26)Reproduction
Complete and self-contained; the only thing that matters is the colon in the last path segment.
Uploading is fine — nothing derives a local path there. Listing is fine too:
Downloading is not:
--continue-on-errormakes no difference — its help text says "Continue after per-file failures", and this failure happens before the per-file phase: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:
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: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 sitesmirror.rs:156,985,1393,1406,1638). TheErrpropagates up and surfaces as the enumeration failure atmirror.rs:690— which is why one bad key kills the entire run rather than one object.crates/cli/src/commands/cp.rs:2719implements the same policy a second time insafe_download_relative_path(), with an explicitcomponent.contains(':')atcp.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
so every single chunk trips the rule. Any bucket written by Loki is therefore unreachable for
rc mirrorandrc 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:
cfg!(windows)(or on the actual destination filesystem), and/or--portable-names, or opt-out via--allow-unportable-names, and/or--continue-on-errordo 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.rsandcp.rscurrently carry two separate copies of this policy, with different error messages for the same rule. Sharing one function would keep them from drifting.