1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
// Copyright 2018 Benjamin Bader
// Copyright 2016 Mozilla Foundation
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use opendal::Operator;
use opendal::OperationContext;
use opendal::services::Azblob;
use opendal_layer_logging::LoggingLayer;
use crate::errors::*;
use super::http_client::set_user_agent;
pub struct AzureBlobCache;
impl AzureBlobCache {
/// Build an Azure Blob Storage operator.
///
/// Two authentication paths are supported, selected by the presence of a
/// connection string:
///
/// * **Shared key (connection string)** — when `connection_string` is
/// `Some`, the operator is built from it, unchanged from historical
/// behavior.
/// * **Microsoft Entra ID (passwordless)** — when `connection_string` is
/// `None`, the operator is built without any account key or SAS token, so
/// OpenDAL (via its bundled `reqsign` dependency) resolves credentials
/// from the ambient environment and signs each request with an
/// `Authorization: Bearer <token>` header. Supported credential sources,
/// in the order `reqsign` tries them: a **service principal**
/// (`AZURE_TENANT_ID` + `AZURE_CLIENT_ID` + `AZURE_CLIENT_SECRET`),
/// **workload identity** (`AZURE_TENANT_ID` + `AZURE_CLIENT_ID` +
/// `AZURE_FEDERATED_TOKEN_FILE`), then a system- or user-assigned
/// **managed identity** via IMDS (`AZURE_CLIENT_ID` optionally selects a
/// user-assigned identity). This path needs an explicit blob endpoint,
/// resolved from `endpoint` (validated and used as-is — for sovereign
/// clouds or a custom endpoint) or synthesized from `storage_account` as
/// `https://{account}.blob.core.windows.net`.
pub fn build(
connection_string: Option<&str>,
container: &str,
key_prefix: &str,
storage_account: Option<&str>,
endpoint: Option<&str>,
) -> Result<Operator> {
// Normalize empty strings to absent up front so a blank value from a
// file config (which, unlike the env parser, does not strip empties)
// behaves like an unset field for BOTH the mutual-exclusivity check and
// the auth-path selection below.
let connection_string = connection_string.filter(|s| !s.is_empty());
let storage_account = storage_account.filter(|s| !s.is_empty());
let endpoint = endpoint.filter(|s| !s.is_empty());
let builder = match connection_string {
Some(connection_string) => {
// Both the env and file config surfaces flow through here. The
// env parser rejects this combination too, but a file config
// bypasses that check, so enforce mutual exclusivity centrally.
if storage_account.is_some() || endpoint.is_some() {
bail!(
"Azure cache accepts either a connection string or a storage account / endpoint (Entra ID), not both."
);
}
Azblob::from_connection_string(connection_string)?
.container(container)
.root(key_prefix)
}
None => {
// Entra ID / passwordless path. Deliberately leave account_key,
// sas_token and connection_string unset: OpenDAL then loads
// Entra credentials (managed identity / workload identity /
// service principal) from the environment via reqsign.
let endpoint = resolve_blob_endpoint(endpoint, storage_account)?;
Azblob::default()
.endpoint(&endpoint)
.container(container)
.root(key_prefix)
}
};
let op = Operator::new(builder)?
.with_context(OperationContext::new().with_http_transport(set_user_agent()))
.layer(LoggingLayer::default());
Ok(op)
}
}
/// Resolve the Azure Blob endpoint for the Entra ID (passwordless) path.
///
/// OpenDAL's Azblob backend requires an explicit endpoint and does not derive
/// one from the account name, so callers must supply either a full `endpoint`
/// (for sovereign clouds or a custom endpoint) or a `storage_account` name from
/// which the public-cloud endpoint is synthesized. An explicit endpoint takes
/// precedence.
fn resolve_blob_endpoint(endpoint: Option<&str>, storage_account: Option<&str>) -> Result<String> {
// Callers normalize empty strings to absent, but do so here too so this
// function is correct in isolation (and unit-testable with blank inputs).
let endpoint = endpoint.filter(|s| !s.is_empty());
let storage_account = storage_account.filter(|s| !s.is_empty());
match (endpoint, storage_account) {
(Some(endpoint), _) => {
let endpoint = endpoint.trim_end_matches('/');
// The endpoint is a trust boundary: the OAuth bearer token is sent to
// whatever host it resolves to. Validate up front so a malformed or
// token-redirecting endpoint fails at startup with an actionable
// message rather than at first cache access, and so the token is never
// sent in cleartext or to an unintended host. Mirrors the S3 backend's
// `http::Uri` endpoint validation.
let uri: http::Uri = endpoint
.parse()
.map_err(|err| anyhow!("Azure endpoint `{endpoint}` is not a valid URI: {err}"))?;
let authority = uri
.authority()
.ok_or_else(|| anyhow!("Azure endpoint `{endpoint}` must include a host."))?;
// Reject `user@host` — the userinfo, not the real host, is what a
// reader would trust, while the bearer token goes to `host`.
if authority.as_str().contains('@') {
bail!("Azure endpoint `{endpoint}` must not contain userinfo (`user@host`).");
}
let host = uri.host().unwrap_or_default();
if host.is_empty() {
bail!("Azure endpoint `{endpoint}` must include a host.");
}
// `http::Uri::host()` returns an IPv6 authority in bracketed form;
// strip the brackets before parsing so `[::1]` (and expanded forms)
// are recognised as loopback.
let host_ip = host
.strip_prefix('[')
.and_then(|h| h.strip_suffix(']'))
.unwrap_or(host);
let is_loopback = host.eq_ignore_ascii_case("localhost")
|| host_ip
.parse::<std::net::IpAddr>()
.map(|ip| ip.is_loopback())
.unwrap_or(false);
match uri.scheme_str() {
Some("https") => {}
Some("http") if is_loopback => {}
_ => bail!(
"Azure endpoint `{endpoint}` must use https (http is allowed only for a loopback host)."
),
}
Ok(endpoint.to_string())
}
(None, Some(storage_account)) => {
// The account name is interpolated into the endpoint host, so reject
// anything that is not a bare account name to keep it from altering
// the host the OAuth bearer token is sent to.
if !storage_account.chars().all(|c| c.is_ascii_alphanumeric()) {
bail!(
"Azure storage account `{storage_account}` is invalid: expected only ASCII alphanumeric characters."
);
}
Ok(format!("https://{storage_account}.blob.core.windows.net"))
}
(None, None) => bail!(
"Azure Entra ID authentication requires a storage account or blob endpoint \
(env `SCCACHE_AZURE_STORAGE_ACCOUNT` / `SCCACHE_AZURE_ENDPOINT`, or the \
`storage_account` / `endpoint` config fields)."
),
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_resolve_blob_endpoint_from_account() -> Result<()> {
assert_eq!(
resolve_blob_endpoint(None, Some("mystorageacct"))?,
"https://mystorageacct.blob.core.windows.net"
);
Ok(())
}
#[test]
fn test_resolve_blob_endpoint_explicit_wins_and_trims() -> Result<()> {
// An explicit endpoint takes precedence over the account name, and any
// trailing slash is trimmed (OpenDAL stores the endpoint without one).
assert_eq!(
resolve_blob_endpoint(
Some("https://acct.blob.core.usgovcloudapi.net/"),
Some("ignored")
)?,
"https://acct.blob.core.usgovcloudapi.net"
);
assert_eq!(
resolve_blob_endpoint(Some("http://127.0.0.1:10000/devstoreaccount1"), None)?,
"http://127.0.0.1:10000/devstoreaccount1"
);
Ok(())
}
#[test]
fn test_resolve_blob_endpoint_requires_a_source() {
assert!(resolve_blob_endpoint(None, None).is_err());
}
#[test]
fn test_resolve_blob_endpoint_rejects_insecure_and_malformed() {
// http against a non-loopback host would leak the OAuth bearer token in
// cleartext, and a scheme-less value is not a usable endpoint.
assert!(resolve_blob_endpoint(Some("http://storage.example.com"), None).is_err());
assert!(resolve_blob_endpoint(Some("storage.example.com"), None).is_err());
// Userinfo would send the token to `host`, not the trusted-looking prefix.
assert!(
resolve_blob_endpoint(
Some("https://acct.blob.core.windows.net@evil.example"),
None
)
.is_err()
);
// An authority with no host is not a usable endpoint.
assert!(resolve_blob_endpoint(Some("https://:443"), None).is_err());
}
#[test]
fn test_resolve_blob_endpoint_accepts_loopback_variants() {
// Azurite / local proxies: http is allowed for every loopback spelling,
// case-insensitively, including bracketed and expanded IPv6.
for ep in [
"http://127.0.0.1:10000/devstoreaccount1",
"http://localhost:10000/devstoreaccount1",
"http://LOCALHOST:10000/devstoreaccount1",
"http://[::1]:10000/devstoreaccount1",
] {
assert!(resolve_blob_endpoint(Some(ep), None).is_ok(), "{ep}");
}
}
#[test]
fn test_resolve_blob_endpoint_rejects_account_that_alters_host() {
// A "storage account" carrying path/scheme separators must not be able
// to redirect where the bearer token is sent.
assert!(resolve_blob_endpoint(None, Some("acct/../evil")).is_err());
assert!(resolve_blob_endpoint(None, Some("acct.evil.com")).is_err());
}
#[test]
fn test_resolve_blob_endpoint_ignores_empty_strings() {
// An empty endpoint must not shadow a valid storage account.
assert_eq!(
resolve_blob_endpoint(Some(""), Some("acct")).unwrap(),
"https://acct.blob.core.windows.net"
);
assert!(resolve_blob_endpoint(Some(""), Some("")).is_err());
}
#[test]
fn test_build_entra_path_builds_operator() {
// No connection string + a storage account selects the Entra path. The
// operator is constructed lazily (no network), so build() succeeds; assert
// the wiring (scheme + container) rather than just that it did not panic.
let op = AzureBlobCache::build(None, "container", "prefix", Some("mystorageacct"), None)
.unwrap();
assert_eq!(op.info().scheme(), "azblob");
assert_eq!(op.info().name(), "container");
}
#[test]
fn test_build_connection_string_path_builds_operator() {
// AccountKey must be valid base64 (opendal validates it at build time).
let conn = "DefaultEndpointsProtocol=https;AccountName=acct;AccountKey=dGVzdGtleQ==;EndpointSuffix=core.windows.net";
let op = AzureBlobCache::build(Some(conn), "container", "prefix", None, None).unwrap();
assert_eq!(op.info().scheme(), "azblob");
assert_eq!(op.info().name(), "container");
}
#[test]
fn test_build_rejects_conflicting_auth_sources() {
// Both operands of the mutual-exclusivity guard are exercised: a
// connection string paired with a storage account, and with an endpoint.
let err = AzureBlobCache::build(Some("conn"), "container", "prefix", Some("acct"), None)
.unwrap_err();
assert!(err.to_string().contains("not both"));
let err = AzureBlobCache::build(
Some("conn"),
"container",
"prefix",
None,
Some("https://acct.blob.core.windows.net"),
)
.unwrap_err();
assert!(err.to_string().contains("not both"));
}
#[test]
fn test_build_requires_an_auth_source() {
// A container with no connection string and no Entra source: the error
// names both the env vars and the config fields, since this branch is
// reachable from a file config too.
let err = AzureBlobCache::build(None, "container", "prefix", None, None).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("storage account or blob endpoint"), "{msg}");
}
#[test]
fn test_build_blank_connection_string_falls_through_to_entra() {
// A blank connection_string from a file config must behave as absent and
// NOT be mistaken for a shared-key source that conflicts with the account.
assert!(
AzureBlobCache::build(Some(""), "container", "prefix", Some("mystorageacct"), None)
.is_ok()
);
}
#[test]
fn test_build_blank_entra_fields_do_not_conflict_with_connection_string() {
// Blank storage_account/endpoint from a file config must not trip the
// mutual-exclusivity guard when a real connection string is set.
let conn = "DefaultEndpointsProtocol=https;AccountName=acct;AccountKey=dGVzdGtleQ==;EndpointSuffix=core.windows.net";
assert!(
AzureBlobCache::build(Some(conn), "container", "prefix", Some(""), Some("")).is_ok()
);
}
}