Repository navigation
Conversation
There was a problem hiding this comment.
Went through this against a local build. The commit split is clean — all three build, test and lint on their own, I checked — and every number in the description is accurate: 37 / 23 / 24 / 36 / 37 tests, clippy clean with -Dwarnings across all five combos, fmt clean. The feature gating really does deliver, 255 crates down to 89 with no dead code under any combo. ETag pinning is implemented correctly and the comment explaining the cache-poisoning failure mode is better than most production code manages.
I also traced the Azure SDK error path, since it looked like the likely SAS leak: it's clean. typespec_client_core wraps reqwest errors in CustomMessage, whose Display drops the inner error — the one that would have appended for url (...&sig=...). Fragile, but it holds.
Two things to fix before merge: the unredacted {source} in storage/mod.rs, and the // fix 3: leftover in azure.rs.
The rest is about size. rati is meant to stay small and readable by people, and this adds ~500 non-test lines for one backend with ~300 of those in credential selection, plus ~104 README lines that are mostly Azure operator documentation. Comments inline.
AI Disclosure - AI was used to help with reviewing this PR ;)
| .await | ||
| .map_err(|e| Error::Io(format!("reading blob response body: {e}")))?; | ||
|
|
||
| // fix 3: the callers index into what comes back assuming it is exactly `length` |
There was a problem hiding this comment.
Leftover from a development task list — "fix 3" doesn't refer to anything in the repo. The same comment in s3.rs:85 is written without the prefix.
There was a problem hiding this comment.
Gone, these comments were duplicates. This guard is now hoisted up to Storage::read_range.
| ) -> Result<Bytes, Error> { | ||
| let range = format!("bytes={}-{}", offset, offset + length - 1); | ||
| let resp = client | ||
| .get_object() |
There was a problem hiding this comment.
No if_match here, while Azure pins every ranged read to the ETag (azure.rs:474). The comment there explains why: replace the archive under a running server and unpinned reads mix bytes from two versions, served back with Cache-Control: immutable.
That argument applies to S3 unchanged. aws-sdk-s3 has .if_match(etag) on GetObject; it needs the ETag carried in the Storage::S3 variant the way AzureBlob carries it.
Deliberate, or just didn't get carried over?
There was a problem hiding this comment.
This is deliberate, I didn't want to change the behavior of S3. I do think this change is worth doing for S3 as well. Let me know if you want that in this PR or a follow up.
The cache poisoning is real and I was able to reproduce it with Azurite.
There was a problem hiding this comment.
This PR, please — as its own commit before the azure one, the way you did the short-read fix.
Reasoning: it's a cache-poisoning bug that exists on main today, independent of Azure, and shipping two backends with different safety properties is worse than shipping neither pinned. Someone reading storage.rs six months from now shouldn't have to work out why one backend fails loudly on a republished archive and the other serves stale offsets under Cache-Control: immutable.
It's about five lines — .if_match(etag) on the get_object builder, plus carrying the etag in Storage::S3 the way AzureBlob already does.
Good that you reproduced it on Azurite. Worth a line in the commit message saying what the failure looks like, since it's the sort of thing that gets "simplified" away later.
There was a problem hiding this comment.
S3 is done in this PR as a separate commit. Both Azure and S3 commits have comments on the failure mode and reproduction.
| azure = ["dep:azure_core", "dep:azure_identity", "dep:azure_storage_blob"] | ||
| # Service-principal certificate auth. Off by default: azure_identity's client_certificate | ||
| # feature links OpenSSL, which the default musl image deliberately avoids. | ||
| azure-client-certificate = ["azure", "azure_identity/client_certificate"] |
There was a problem hiding this comment.
The OpenSSL claim checks out — openssl v0.10.81 ← azure_identity v1.0.0. But that reads as an argument for not supporting certificate auth, rather than for a third feature to carry it.
Dropping it removes a feature, a CredentialKind arm, a detection row, a README section and a build combo. Is anyone asking for certificate auth?
There was a problem hiding this comment.
azure-client-certificate and CredentialKind arm is gone. openssl is out of Cargo.lock.
| run: cargo test | ||
|
|
||
| - name: Run tests (no default features) | ||
| run: cargo test --no-default-features |
There was a problem hiding this comment.
This covers the smallest build, but not the two that actually ship — --features s3 and --features azure alone. You verified both by hand; nothing stops them rotting.
cargo clippy above is also missing --all-targets, so test code isn't linted.
There was a problem hiding this comment.
Both fixed, the matrix now covers all combinations.
| @@ -0,0 +1,170 @@ | |||
| //! Storage backends for a tar archive. Hides whether bytes come from S3, Azure Blob, | |||
There was a problem hiding this comment.
On the split itself: storage as a boundary is right. archive.rs was doing tar parsing and I/O both, and it's down from 1063 to 878.
Four files is where I'd push back. local.rs is 79 lines — one open, one read — and s3.rs is 117. Folding both into mod.rs gives a single ~370-line storage.rs beside azure.rs, and one less hop when following a read. The feature gate works inline just as well:
#[cfg(feature = "s3")]
mod s3 { ... }azure.rs at 819 lines clearly earns its own file. Not a blocker — your call on which reads better.
There was a problem hiding this comment.
azure.rsat 819 lines clearly earns its own file.
Actually that's a good sign that something should be simplified or less vibe-coded... How is it possible that AWS requires 7 times less code?
There was a problem hiding this comment.
Refactored to match your structure, good suggestion! 370 matched the outcome exactly.
I cut this down another 143 lines. Most of the cut was credential selection.
On AWS vs. Azure, it comes down to a few details:
- The credentials / identity libraries. Before version 1.0 of the rust
azure_identitycrate there was a niceDefaultAzureCredentialthat aligned with theaws_config::load_defaults()call in the s3 code. That was deliberately removed in 1.0 so you have to explicitly pick the credential type. - ETag pinning exists on the Azure side and not on S3
- The s3 protocol in the URL makes parsing the S3 URLs unambiguous
There was a problem hiding this comment.
Your DefaultAzureCredential explanation checks out — I looked at the crate rather than take it on trust. azure_identity 1.0.0 exports only concrete credentials (azure_cli, client_secret, managed_identity, workload_identity, …) and the string DefaultAzureCredential appears nowhere in its source. So the asymmetry with aws_config::load_defaults() is real and not something you invented.
Breaking the gap down, non-test lines:
| Concern | S3 | Azure |
|---|---|---|
| Recognising the URL | 4 | ~66 |
| Getting a credential | 1 | ~148 |
| Open + read bytes | ~73 | ~125 |
- URL recognition — forced by Azure.
s3://is a scheme nothing else can claim; blob URLs are barehttps://. But it's forced only for crediting, not for routing, which is theazure.rs:55comment. - Credentials — the SDK's fault that you have to choose, but rati's choice to expose the choice. That's the thread above.
- Open + read — I'd retract part of my round-1 complaint here. I'd called the ranged probe a wasted round trip versus
get_properties(); now that the 512 bytes feedArchiveSource::prefetch, Azure opens in one request where S3 needsHeadObjectplus a read. That's better than the S3 path, not worse. The rest of the column is the ETag pinning S3 doesn't have.
So roughly 215 of the ~280-line gap is genuinely forced, and I can point at the cause for each. It's less vibe-coded than the raw number suggests. The discretionary part is the credential layer, and that's where I'd spend the cuts — not on the I/O, which is fine.
On the fold itself: 370 matching was coincidence and the file reads in the right order. See the note on storage.rs:166 about the inner mod blocks — that's the last bit of the split still standing.
There was a problem hiding this comment.
The credential layer was reworked and I cut out all the Azure command arguments and instead detect them from the env in a way consistent with other Azure SDKs. That cut the code down and clean up the README.md and cli.
|
@kinkard Thanks for the thorough review! I took nearly every suggestion. Let me know if this needs more changes and we can keep iterating. |
kinkard
left a comment
There was a problem hiding this comment.
Re-verified everything on f2b4d667. Tests 35 / 22 / 23 / 34 across the four combos, clippy --all-targets -Dwarnings clean on each, fmt clean, and all four commits build/test/lint independently — I rechecked that since the commit list was restructured. c349cb63 is properly self-contained at +12/-1 and lands before the azure commit. ETag pinning survived the refactor intact, the hoisted short-read guard is correct, and there's no dead code or stale docs from the deletions. 13 of 14 items landed.
Thanks for the rework — it's a real improvement and you've been precise about what you changed.
That said, I want to change the direction of one thing rather than keep trimming it, and be straight that my round-1 comment asked the wrong question.
I asked "which of these seven do you expect anyone to use," which invited a trim, and you trimmed to five. The question I should have asked is whether rati should be selecting Azure credentials at all. I don't think it should. CredentialKind is Azure's problem model showing up in rati's CLI — a tile server's --help shouldn't be teaching anyone the difference between workload identity and a service principal. Details in the azure.rs:86 thread, with a ~20-line sketch.
Same instinct behind the is_azure_url comment: rati will never have a generic HTTP backend, so any http(s) URL is Azure by elimination and none of the host-matching is needed for routing. That one deletes the branch the round-1 SAS leak lived in, makes Azurite work with no special case, and lets custom domains and Front Door work — which they can't today.
Between the two, the backend lands around 200 lines instead of 359, with no Azure vocabulary in rati's interface. The I/O half is already good and I'm not asking you to touch it — a one-request open that prefetches the first 512 bytes is tighter than what S3 does, and the pinning is a safety property S3 is missing.
One regression to fix regardless of direction: removing non_empty means an empty RATI_AZURE_USER_ASSIGNED_ID now reaches IMDS as client-id=&, which can silently resolve to the system-assigned identity. One-line fix on main.rs:66.
On the S3 question you asked — yes, in this PR. Reasoning in that thread.
| #[cfg(feature = "azure")] | ||
| fn azure_options(&self) -> storage::AzureOptions<'_> { | ||
| storage::AzureOptions { | ||
| user_assigned: self.azure_user_assigned_id.as_deref(), |
There was a problem hiding this comment.
Removing non_empty went one step too far — this is a regression, not a simplification.
clap with env turns a present-but-empty variable into Some(""), not None. So RATI_AZURE_USER_ASSIGNED_ID="" — an unset Helm value or compose variable — reaches build_credential, which wraps it unconditionally:
let options = user_assigned.map(|id| ManagedIdentityCredentialOptions {
user_assigned_id: Some(UserAssignedId::ClientId(id.to_string())),and azure_identity formats it verbatim into client-id=&. Best case an opaque 400 from the token endpoint. On the App Service and Container Apps endpoints an empty id can resolve to the system-assigned identity — a different principal than the operator asked for, with nothing in the log saying so.
require() at azure.rs:184-193 already does exactly the right thing (.filter(|v| !v.is_empty())); this path just doesn't get it. No value_parser needed:
| user_assigned: self.azure_user_assigned_id.as_deref(), | |
| user_assigned: self.azure_user_assigned_id.as_deref().filter(|id| !id.is_empty()), |
My fault for arguing the 8 lines weren't worth it — I was looking at the clap error message and not at what an empty id does to IMDS.
There was a problem hiding this comment.
This issue was removed when the Azure CLI arguments were removed.
| } | ||
|
|
||
| #[cfg(feature = "azure")] | ||
| if source.starts_with("https://") || source.starts_with("http://") { |
There was a problem hiding this comment.
This arm disappears entirely under the is_azure_url change — see the comment on azure.rs:55.
Worth noting because it's the branch the round-1 SAS leak lived in. Deleting the branch deletes the bug class rather than keeping the fix around to be re-broken.
There was a problem hiding this comment.
Done, this arm is gone. is_azure_url is a simple scheme check.
| let credential = build_credential(&env, kind, user_assigned)?; | ||
|
|
||
| let client = BlobClient::new(parsed, credential, None) | ||
| .map_err(|e| Error::Io(format!("creating blob client: {e}")))?; |
There was a problem hiding this comment.
BlobClient::new's two error paths embed the raw URL — format!("{blob_url} is not a valid base URL") and format!("{blob_url} must use https") — and this lands in Error::Io, which main.rs .expect()s straight to stderr with the SAS in it.
Unreachable today only because select_credential_kind forces Anonymous on every non-https URL, so the must use https branch can't fire with Some(credential). That's the one spot where the redaction discipline depends on a third-party crate's internals instead of your own code.
| .map_err(|e| Error::Io(format!("creating blob client: {e}")))?; | |
| let client = BlobClient::new(parsed, credential, None) | |
| .map_err(|_| Error::Io(format!("creating blob client for {}", redact(url))))?; |
There was a problem hiding this comment.
I added a comment explaining why the SDK error is dropped.
| /// mtime formatted as an HTTP-date. | ||
| pub(super) fn open_local(path: &str) -> Result<(Storage, ArchiveSource), Error> { | ||
| let metadata = std::fs::metadata(path) | ||
| .map_err(|e| Error::Io(format!("stat({}) failed: {e}", redact(path))))?; |
There was a problem hiding this comment.
redact() now runs on local filesystem paths too. An archive at /data/tiles?v2.tar reports stat(/data/tiles) failed: No such file or directory — pointing at a path that isn't the one you passed.
Cosmetic, but it turns a clear error into a confusing one. Either apply redact only to URL-shaped sources, or say in the doc comment at :44-49 that it truncates paths at ? as well.
There was a problem hiding this comment.
Fixed in redact(), it only truncates http:// and https:// sources now.
| ``` | ||
|
|
||
| Credentials are resolved in this order. The first row whose condition holds wins; | ||
| `--azure-credential <kind>` skips detection entirely. |
There was a problem hiding this comment.
Two things here don't match the code.
--azure-credential <kind> does not skip detection entirely — azure.rs:145 returns Anonymous before the override is consulted:
if insecure || !blob_host || has_sas_token(url) {
return CredentialKind::Anonymous;
}
if let Some(kind) = override_kind { return kind; }So --azure-credential client-secret against a SAS-bearing URL is silently discarded. Your own test asserts it (azure.rs:487-490). For the plaintext case that's correct and should stay; for the SAS case, throwing away an explicit operator choice without a warning is a footgun.
Row 1 is also missing a condition — the code checks !blob_host too, so an unrecognised host lands in row 1 as well.
Most of this evaporates if the credential layer collapses (see the azure.rs:86 thread).
There was a problem hiding this comment.
This issues are gone now that now Azure CLI args exist anymore.
| // Callers index into the result assuming it is exactly `length` bytes; | ||
| // `scan_tar_headers` subtracts from `chunk.len()` and would underflow on a short | ||
| // read. A ranged GET may return less, so enforce it here once for every backend. | ||
| if data.len() as u64 != length { |
There was a problem hiding this comment.
c349cb63 is a behaviour fix with a good commit message and no test. grep -rn "short read" src/ finds only the format string.
The guard itself is right — I checked all seven read_range call sites in archive.rs and none intentionally overshoots EOF (scan_tar_headers clamps with CHUNK_SIZE.min(archive_size - offset)), so no false positives, and using != rather than < catches an over-long response too. It just deserves a test pinning it.
| } | ||
|
|
||
| /// Filesystem-backed tar archive: `stat` for metadata, positional reads for ranges. | ||
| mod local { |
There was a problem hiding this comment.
The fold landed at 370 lines, but local and s3 survive as nested inline mod blocks — so the four-file split became a four-module split in one file.
The cost is visible: six pub(super) markers (:177, :212, :243, :250, :300) that would be plain private fns, and two duplicated use blocks. The only thing the module buys is one #[cfg(feature = "s3")], which sits just as well on the individual items.
Dissolving both into plain functions is what I was asking for. Not a blocker.
There was a problem hiding this comment.
Dissolved. local and s3 are plain private functions now.
| Azure SDK reads that variable itself for workload identity and service-principal auth, so | ||
| adopting it would hijack an already-meaningful setting. | ||
|
|
||
| Whichever credential is used needs **Storage Blob Data Reader** on the account or container. |
There was a problem hiding this comment.
Three lines still read as Azure documentation rather than rati documentation:
- this one — Storage Blob Data Reader is Azure RBAC, and the same fact is already in the error at
azure.rs:275-276, which is where an operator actually hits it :70-72— "a container image without the Azure CLI falls through to row 5 and fails looking foraz" is troubleshooting prose for a failure the error message should carry:94— theContent-Encodingnote: the second clause (confuses CDNs in front of rati) earns its place, the first is Azure trivia
The cut so far is real, not reshuffled — this is the last 10%.
Structural note: Azure gets 44 lines and its own ###, S3 gets one table row and no prose. Worth being a decision rather than a drift.
There was a problem hiding this comment.
This is gone, now the docs are only how Rati handles environment settings and which credentials it selects. Let me know if you want an S3 section in the README.md to get parity with Azure.
archive.rs carried the Storage enum and both backends alongside the tar and index parsing. This moves them to src/storage.rs, leaving archive.rs responsible for the archive format only. The open_* functions return an ArchiveSource struct rather than a 4-tuple so that adding a backend does not churn every call site. Also drops the local backend's incidental dependency on aws_sdk_s3, which it used only to format an HTTP-date; httpdate is a zero-dependency IMF-fixdate formatter and lets the backends be built independently. No behaviour change.
read_range hands its result to callers that index into it assuming it is exactly `length` bytes. scan_tar_headers computes `chunk.len() - local`, which underflows if the backend returns fewer bytes than the range asked for, and the index path reads fixed-size records straight out of the buffer. A ranged GET is not obliged to return the whole range, so treat a short read as an error naming the offset and both lengths. This changes the S3 backend from tolerating a short GetObject to failing hard, which is the point: a truncated buffer is worse than a visible error. Enforced once in Storage::read_range rather than per backend, so the invariant holds for any backend added later; the local backend already gets it from read_exact_at.
The tile index maps ids to byte offsets inside one specific version of the object. Replace the archive under a running server and those offsets point into different data: GetObject returns whatever now occupies that range, and rati serves it under the *old* ETag with Cache-Control: immutable, so every client and CDN in front caches wrong bytes as if they were permanent. Nothing fails, which is what makes it dangerous. Reproduced against MinIO before the fix: replacing the object under a running server kept the tile endpoint at 200 while its sha256 changed, still labelled with the previous ETag and marked immutable. Carrying the ETag captured at open in the Storage::S3 variant and passing it as if_match turns that into a visible 500.
A deployment that only reads local archives no longer pulls in the AWS SDK and its TLS stack. CI becomes a matrix over the feature combinations so both paths stay honest, and lints test code too via --all-targets.
Serves tiles from a tar in Azure Blob via ranged reads. The SDK's HttpRange::new(offset, length) maps 1:1 onto Storage::read_range, so nothing above the storage layer changes. rati has no generic HTTP backend and should not grow one, so routing needs no host inspection: any http(s) archive is Azure by elimination. Whether a host is trusted enough to be sent a credential is a separate question, and the only one that is load-bearing -- a bearer token goes out only to an https host carrying a known blob suffix, so an archive behind a CDN or a custom domain is read anonymously or with a SAS. That also means Azurite needs no special case at all. There are no Azure command-line flags. azure_identity 1.0 ships no DefaultAzureCredential, but that is a reason to pick sensible defaults rather than to put Azure's identity model in a tile server's --help. The credential comes from the same environment variables every other Azure SDK reads, in the order DefaultAzureCredential uses elsewhere: a projected token file means workload identity, a tenant/client/secret triple means a service principal, and otherwise managed identity -- user-assigned when AZURE_CLIENT_ID names one, system-assigned when it does not. That ordering is also what disambiguates AZURE_CLIENT_ID, which all three consult. An empty value is not a value: an empty id reaches IMDS as `client-id=&`, which can silently resolve to the system-assigned identity. A service principal is kept deliberately. A managed identity only exists on Azure compute, and workload identity federation covers a specific set of issuers, so without it a private archive would be readable only from Azure -- and it is also the local-development path against real blob storage. Metadata comes from a 512-byte ranged download rather than get_properties: BlobClientGetPropertiesResult is a unit struct that surfaces its values as raw headers, whereas download returns a typed BlobDownloadProperties. That probe is the first tar block, so it is handed back in ArchiveSource::prefetch and Archive::open reads it instead of fetching the same bytes twice. The blob's total size is parsed out of Content-Range, since a ranged response reports the length of the range and the SDK keeps its own ContentRange type private. Reads are pinned with if_match to the ETag captured when the archive was opened, for the same reason the S3 backend now is. The azure crates are taken with default-features = false. Their defaults enable reqwest's gzip and deflate features, which would let the client transparently decompress a response and change the byte count under a range read -- the SDK's own download() documents that automatic decompression breaks partitioned downloads.
|
@kinkard - Thanks again! I reworked the credentials layer to completely remove all Azure command line arguments and instead detect them from the env in a way consistent with every other Azure SDK. I'd been wondering the same thing: whether to remove credential selection entirely. The
is_azure_url is a scheme check now; has_blob_host_suffix survives only as the credential gate, per your comment. Dropped .blob.core.cloudapi.de (the SDK marks that cloud [Obsolete("closed on October 29th, 2021")]). Consequence worth naming: a CDN/custom-domain front end now reads anonymously or by SAS only. The empty-id regression you flagged is fixed structurally: the flag is gone, and the env reader treats empty as unset, so an empty AZURE_CLIENT_ID can't reach IMDS as client-id=& — it deliberately selects the system-assigned identity, and the startup log says which one was chosen. S3 ETag pinning added as its own commit before the Azure one. Tests were also added to verify both the pinning and short-read guards. |
|
We are using this in production and it's working well for us. Anything else to address here? I can get S3 up to parity with Azure Blob in |
Folding `local` and `s3` into plain functions left duplicated `#[cfg(feature = "s3")]` attributes on `parse_s3_url` and `open_s3`, and each old module doc comment attached itself to the first function after it. Also drop the README's Storage Blob Data Reader line. That is Azure RBAC, and the error returned when the first read fails already says it.
|
@kinkard All the round 2 feedback is addressed. When you have time can you take another look and merge if it's ready? I can update the README to add S3 docs to get parity with Azure in this PR or a follow up if you want that. |
|
@kdbrooks thanks for your contribution! Unfortunately, I still have trust issues with LLMs and for the final round I need to find time and desire to sit down and review 1.5k lines of new code in that tiny project that was 1.5k lines of code in total (yeah, your PR basically doubles the size of this project). No promises here as I'm unfortunately quite heavily occupied at my daytime job |
I understand, some of it is refactoring existing code to be modular and optional. Let me know how I can help, if this is too big maybe I can split it up into smaller PRs. |
Adds Azure Blob Storage as a third archive source alongside S3 and the local filesystem, and makes both cloud backends optional Cargo features.
Fully tested with real Azure Storage accounts, managed identities, service principals. Azurite is a good option for integration testing without needing Azure.
S3 ETag pinning prevents rati from serving up bad data and poisoning the cache when the blob changes under it. Now it fails loudly and asks for a restart.
Closes #3
AI Disclosure - AI was used to help develop this specifically Claude Opus 5 xhigh effort. The code was reviewed and tested throughly by me and this PR description was written without AI.
Testing
Fixture is a real Valhalla build of Indiana — 477 MB, 226 tiles plus
index.bin, members spanning 792 B to 44.5 MB. The largest is well past the SDK's 4 MiBDEFAULT_DOWNLOAD_PARTITION_SIZE, so partitioned downloads get exercised and not just single-range reads.Unit tests and lints across every feature combination, plus each commit independently:
-Dwarningss3+azure)--no-default-features--features s3--features azurecargo fmtclean,cargo metadata --lockedpasses. All three commits build, test and lint on their own, so the refactor can be verified as behaviour-preserving before the feature is read.End to end, serving tiles over HTTP:
The storage account ran with
allowSharedKeyAccess = false, so every one of those authenticated through Entra — there was no key path to fall back on.Two behaviours worth calling out because they're hard to test by inspection:
200to500, with412 ConditionNotMetin the log. Without the pin that read returns 200 and wrong bytes, cached asimmutable.sig=present in no log line.Not verified: Workload Identity (needs an AKS cluster with the webhook), Azure Pipelines (needs an ADO service connection), and client certificate (builds under its feature, but no certificate-backed service principal was exercised). Credential selection for all three is unit-tested; the token exchange isn't.