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
//! Shared model-cache root + blob-dedup-key classifier for `APR_MODELS`
//! (CRUX-A-21). Parity with Ollama's `OLLAMA_MODELS` for multi-user /
//! systemd-managed deployments.
//!
//! Contract: `contracts/crux-A-21-v1.yaml`.
//!
//! Three pure algorithm-level sub-claims live here:
//!
//! 1. `resolve_registry_root(env, home)` is deterministic: if `APR_MODELS`
//! is set non-empty it wins; otherwise `$HOME/.apr/models`. No I/O —
//! the caller is responsible for creating the directory.
//!
//! 2. `blob_path_for(root, sha256_hex)` produces `{root}/blobs/sha256-<hex>`.
//! Dedup across users is guaranteed *by construction*: two processes
//! pulling the same content-addressed blob write to the same path, so
//! the filesystem collapses to one inode — the necessary condition
//! for FALSIFY-CRUX-A-21-001.
//!
//! 3. `classify_pull_permission_outcome(io_err_kind)` maps an
//! I/O-error variant onto the contract-defined exit code (13 for
//! permission-denied, with an actionable daemon-user hint). This is
//! the necessary condition for FALSIFY-CRUX-A-21-002: the pipeline
//! must exit 13 on EACCES, not silently fall back to `$HOME`.
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
/// Well-known env var names. Matches Ollama's convention for parity.
pub const APR_MODELS_ENV: &str = "APR_MODELS";
/// Default subdirectory under the user's home when `APR_MODELS` is unset.
pub const DEFAULT_REGISTRY_SUBDIR: &str = ".apr/models";
/// Subdirectory inside the registry root where content-addressed blobs live.
pub const BLOBS_SUBDIR: &str = "blobs";
/// Resolve the registry root for `apr pull`.
///
/// Precedence:
/// 1. `apr_models_env` if `Some(...)` and non-empty after trim
/// 2. `home/.apr/models`
///
/// Returns `Err` if `home` is empty (no fallback available).
pub fn resolve_registry_root(
apr_models_env: Option<&str>,
home: &Path,
) -> Result<PathBuf, RegistryRootError> {
if let Some(env) = apr_models_env {
let trimmed = env.trim();
if !trimmed.is_empty() {
return Ok(PathBuf::from(trimmed));
}
}
if home.as_os_str().is_empty() {
return Err(RegistryRootError::MissingHome);
}
Ok(home.join(DEFAULT_REGISTRY_SUBDIR))
}
/// Error cases for `resolve_registry_root`.
#[derive(Debug, PartialEq, Eq)]
pub enum RegistryRootError {
/// Neither `APR_MODELS` nor `home` was usable. Callers MUST exit with
/// a configuration error rather than silently picking `/tmp` or similar.
MissingHome,
}
/// Compute the on-disk path for a content-addressed blob.
///
/// The `sha256_hex` must be the lowercase 64-character hex digest of the
/// blob contents. Returns `Err` for empty / malformed digests because
/// collapsing a malformed digest into a shared blob dir would silently
/// lose dedup correctness.
pub fn blob_path_for(root: &Path, sha256_hex: &str) -> Result<PathBuf, BlobPathError> {
if sha256_hex.len() != 64 {
return Err(BlobPathError::WrongLength(sha256_hex.len()));
}
if !sha256_hex
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
{
return Err(BlobPathError::NonHexLower);
}
let filename = format!("sha256-{sha256_hex}");
Ok(root.join(BLOBS_SUBDIR).join(filename))
}
/// Error cases for `blob_path_for`.
#[derive(Debug, PartialEq, Eq)]
pub enum BlobPathError {
/// sha256 hex must be exactly 64 characters.
WrongLength(usize),
/// Must be lowercase ASCII hex; uppercase or non-hex bytes are rejected.
NonHexLower,
}
/// Contract-defined exit outcome for an `apr pull` permission scenario.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PullPermissionOutcome {
/// All good — proceed with the fetch.
Ok,
/// EACCES on the registry root → exit 13 with a daemon-user hint.
/// The hint is the fixed string returned here so callers emit a
/// consistent user-facing message across platforms.
PermissionDenied { exit_code: i32, hint: &'static str },
/// Registry root is missing and cannot be created.
NotFound { exit_code: i32 },
/// Any other I/O failure — generic exit 1.
Other { exit_code: i32, kind: ErrorKind },
}
/// Map an `std::io::ErrorKind` onto the contract's exit-code table.
///
/// Contract FALSIFY-CRUX-A-21-002: an unprivileged user must see exit
/// code 13 (Unix convention for EACCES) with a hint pointing at the
/// daemon user, not a silent fall-through that writes to `$HOME`.
pub fn classify_pull_permission_outcome(kind: ErrorKind) -> PullPermissionOutcome {
match kind {
ErrorKind::PermissionDenied => PullPermissionOutcome::PermissionDenied {
exit_code: 13,
hint: "permission denied on APR_MODELS — run as daemon user or adjust mode",
},
ErrorKind::NotFound => PullPermissionOutcome::NotFound { exit_code: 1 },
other => PullPermissionOutcome::Other {
exit_code: 1,
kind: other,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
// ===== resolve_registry_root =====
#[test]
fn env_wins_when_set() {
let got =
resolve_registry_root(Some("/var/lib/apr/models"), Path::new("/home/user")).unwrap();
assert_eq!(got, PathBuf::from("/var/lib/apr/models"));
}
#[test]
fn env_whitespace_trimmed() {
let got = resolve_registry_root(Some(" /srv/apr "), Path::new("/home/u")).unwrap();
assert_eq!(got, PathBuf::from("/srv/apr"));
}
#[test]
fn empty_env_falls_back_to_home() {
for env in [Some(""), Some(" "), None] {
let got = resolve_registry_root(env, Path::new("/home/u")).unwrap();
assert_eq!(got, PathBuf::from("/home/u/.apr/models"));
}
}
#[test]
fn missing_home_without_env_errors() {
let err = resolve_registry_root(None, Path::new("")).unwrap_err();
assert_eq!(err, RegistryRootError::MissingHome);
}
#[test]
fn env_alone_sufficient_even_without_home() {
// If APR_MODELS is set, we do NOT need $HOME at all — systemd
// deploys often have a fictitious daemon $HOME.
let got = resolve_registry_root(Some("/var/apr"), Path::new("")).unwrap();
assert_eq!(got, PathBuf::from("/var/apr"));
}
#[test]
fn resolve_is_deterministic() {
let a = resolve_registry_root(Some("/x"), Path::new("/h")).unwrap();
let b = resolve_registry_root(Some("/x"), Path::new("/h")).unwrap();
assert_eq!(a, b);
}
// ===== blob_path_for =====
#[test]
fn blob_path_uses_sha256_prefix() {
let hex = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
let p = blob_path_for(Path::new("/var/apr"), hex).unwrap();
assert_eq!(
p,
PathBuf::from(
"/var/apr/blobs/sha256-e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
)
);
}
#[test]
fn blob_path_dedup_is_by_construction() {
// FALSIFY-CRUX-A-21-001 sub-claim: identical sha256 under the
// same root produces identical path. The filesystem collapses
// two concurrent pulls to one inode automatically.
let hex = "a".repeat(64);
let a = blob_path_for(Path::new("/shared"), &hex).unwrap();
let b = blob_path_for(Path::new("/shared"), &hex).unwrap();
assert_eq!(a, b);
}
#[test]
fn blob_path_different_hash_yields_different_path() {
let a = blob_path_for(Path::new("/r"), &"a".repeat(64)).unwrap();
let b = blob_path_for(Path::new("/r"), &"b".repeat(64)).unwrap();
assert_ne!(a, b);
}
#[test]
fn blob_path_rejects_wrong_length() {
for hex in ["", "abc", &"a".repeat(63), &"a".repeat(65)] {
let err = blob_path_for(Path::new("/r"), hex).unwrap_err();
assert!(matches!(err, BlobPathError::WrongLength(_)));
}
}
#[test]
fn blob_path_rejects_uppercase_hex() {
// Mixed case would break dedup (two different paths for the
// same content). Reject uppercase outright.
let hex = "A".repeat(64);
assert_eq!(
blob_path_for(Path::new("/r"), &hex).unwrap_err(),
BlobPathError::NonHexLower
);
}
#[test]
fn blob_path_rejects_non_hex_bytes() {
let mut hex = "a".repeat(63);
hex.push('z');
assert_eq!(
blob_path_for(Path::new("/r"), &hex).unwrap_err(),
BlobPathError::NonHexLower
);
}
#[test]
fn blob_dir_is_always_under_root() {
let hex = "0".repeat(64);
let root = Path::new("/some/registry/root");
let p = blob_path_for(root, &hex).unwrap();
assert!(p.starts_with(root));
assert_eq!(p.parent().unwrap(), root.join(BLOBS_SUBDIR));
}
// ===== classify_pull_permission_outcome =====
#[test]
fn permission_denied_yields_exit_13_with_hint() {
// FALSIFY-CRUX-A-21-002: EACCES must exit 13 with actionable hint.
let r = classify_pull_permission_outcome(ErrorKind::PermissionDenied);
match r {
PullPermissionOutcome::PermissionDenied { exit_code, hint } => {
assert_eq!(exit_code, 13);
assert!(
hint.to_lowercase().contains("permission"),
"hint should mention permission: {hint}"
);
assert!(
hint.to_lowercase().contains("daemon") || hint.to_lowercase().contains("mode"),
"hint should be actionable (daemon/mode): {hint}"
);
}
other => panic!("expected PermissionDenied, got {other:?}"),
}
}
#[test]
fn not_found_yields_exit_1() {
let r = classify_pull_permission_outcome(ErrorKind::NotFound);
assert_eq!(r, PullPermissionOutcome::NotFound { exit_code: 1 });
}
#[test]
fn generic_io_errors_yield_exit_1() {
for k in [
ErrorKind::UnexpectedEof,
ErrorKind::Interrupted,
ErrorKind::TimedOut,
] {
match classify_pull_permission_outcome(k) {
PullPermissionOutcome::Other { exit_code, kind } => {
assert_eq!(exit_code, 1);
assert_eq!(kind, k);
}
other => panic!("expected Other for {k:?}, got {other:?}"),
}
}
}
#[test]
fn permission_classifier_is_deterministic() {
let a = classify_pull_permission_outcome(ErrorKind::PermissionDenied);
let b = classify_pull_permission_outcome(ErrorKind::PermissionDenied);
assert_eq!(a, b);
}
#[test]
fn permission_classifier_never_returns_ok_on_eacces() {
// Strengthens FALSIFY-CRUX-A-21-002: no path through the
// classifier returns `Ok` when `ErrorKind::PermissionDenied` is
// passed. The silent-fall-through bug that motivated the gate
// is structurally impossible here.
let r = classify_pull_permission_outcome(ErrorKind::PermissionDenied);
assert!(!matches!(r, PullPermissionOutcome::Ok));
}
}