ignition_core/client/workspace.rs
1//! The workspace engine's pure core (13-02) — the injective
2//! user-path → local-fs-path mapping, plus (Task 3) the
3//! [`MemberSource`] abstraction that lets the proven v1.0 member
4//! engine ([`crate::client::resources`]) speak EITHER a gateway
5//! export zip OR a checked-out directory tree.
6//!
7//! ## The escaping scheme (planner-locked, property-test-pinned)
8//!
9//! Member user paths from a gateway export are HOSTILE: two members
10//! may differ only by case (`P13/A` vs `p13/a`), carry traversal
11//! shapes (`..`), NULs, control bytes, arbitrary unicode (NFC vs NFD
12//! decompositions), or percent signs. The mapping onto a checked-out
13//! tree must be INJECTIVE — two members landing on one local path is
14//! silent data loss at checkout and server-side deletion on push
15//! (roadmap Pitfall W1) — and REVERSIBLE so push can walk back.
16//!
17//! Per-segment percent-encoding:
18//!
19//! - Safe segment bytes are `[A-Za-z0-9._-]`; names over the safe
20//! alphabet land on disk BYTE-IDENTICAL (diffable, editor-friendly).
21//! - EVERY other byte — `%` itself, spaces, control chars, non-ASCII
22//! — percent-encodes as `%XX`. Because `%` is escaped too, `%XX`
23//! is the ONLY escape form and decoding is unambiguous (no
24//! double-decode trap: the literal name `%2e` round-trips as
25//! `%2e`, never collapsing into `.`).
26//! - Refusals are fail-closed `CoreError::InvalidInput` naming the
27//! offending member path — never sanitized, never
28//! last-write-wins, and NO new exit slugs (planner lock: new slugs
29//! are Three-Place ATOMIC events; mapping refusals ride the
30//! existing `invalid_input` slug, exit 2):
31//! - a segment exactly `.` or `..` (traversal-shaped — refused,
32//! never escaped-around);
33//! - NUL anywhere in the member path;
34//! - an empty segment (`//`, leading or trailing slash) — same
35//! refusal class as `.`;
36//! - an ESCAPED segment exceeding 255 bytes — the on-disk name cap
37//! (APFS/ext4): the escaped form is the name that must land, so
38//! an oversized one refuses at mapping time rather than failing
39//! mid-write (escaping can inflate 1 byte to 3, so the cap is
40//! checked on the OUTPUT).
41//! - ASCII-case collisions are refused at SET level by
42//! [`build_mapping`] (APFS is the deployment surface —
43//! case-insensitive by default): two members whose local paths
44//! differ only by ASCII case would fold onto one file, so the
45//! mapping build refuses naming BOTH member paths. Unicode case is
46//! NOT folded beyond ASCII — NFC/NFD variants differ byte-wise and
47//! stay distinct files (the recorded manifest holds the exact
48//! mapping either way).
49//!
50//! ## Recorded, never recomputed downstream
51//!
52//! [`build_mapping`] runs ONCE at checkout; the manifest (13-03)
53//! stores the gateway↔local pairs. status/push READ the manifest —
54//! re-deriving the mapping is Pitfall W1's documented anti-pattern.
55//! These functions are the ONLY mapping implementation; the property
56//! suite (`tests/workspace_path_mapping.rs`) machine-proves the
57//! bijection/round-trip/refusal properties over hostile corpora —
58//! SC-2's "property tests pass" at the mapping layer.
59
60use std::collections::{BTreeMap, BTreeSet};
61use std::path::PathBuf;
62
63use crate::client::resources;
64use crate::error::CoreError;
65
66/// The maximum on-disk segment length the mapping will emit — the
67/// classic 255-byte filesystem name cap. Checked on the ESCAPED form
68/// (that is the byte string that must land on disk).
69const MAX_ESCAPED_SEGMENT_BYTES: usize = 255;
70
71/// The bytes a segment may carry through to disk UNESCAPED — the
72/// planner-locked safe alphabet. Everything else rides as `%XX`.
73fn is_safe_segment_byte(byte: u8) -> bool {
74 byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')
75}
76
77/// Upper-case hex digits — the pinned `%XX` spelling (lowercase is
78/// ACCEPTED on decode — valid hex is valid hex — but escape emits
79/// exactly this form).
80const HEX: &[u8; 16] = b"0123456789ABCDEF";
81
82/// Escape ONE member-path segment for the local filesystem:
83/// percent-encode every byte outside the safe alphabet. Refuses
84/// (fail-closed, `invalid_input`):
85/// - the exact segments `.` and `..` — traversal-shaped, refused
86/// rather than escaped-around;
87/// - the empty segment (same refusal class as `.`);
88/// - any NUL byte;
89/// - an escaped form longer than 255 bytes (the on-disk name cap —
90/// checked on the OUTPUT, since escaping inflates).
91///
92/// The error names the offending segment; the member-path callers
93/// ([`local_path_for`], [`build_mapping`]) wrap it with the full
94/// member path.
95pub fn segment_escape(segment: &str) -> Result<String, CoreError> {
96 if segment.is_empty() || segment == "." || segment == ".." {
97 return Err(CoreError::InvalidInput {
98 reason: format!(
99 "workspace member segment is traversal-shaped or empty: \"{segment}\" — \
100 refusing rather than escaping around it"
101 ),
102 });
103 }
104 if segment.bytes().any(|byte| byte == 0) {
105 return Err(CoreError::InvalidInput {
106 reason: format!("workspace member segment carries a NUL byte: \"{segment}\""),
107 });
108 }
109 let mut escaped = String::with_capacity(segment.len());
110 for byte in segment.bytes() {
111 if is_safe_segment_byte(byte) {
112 escaped.push(byte as char);
113 } else {
114 escaped.push('%');
115 escaped.push(char::from(HEX[usize::from(byte >> 4)]));
116 escaped.push(char::from(HEX[usize::from(byte & 0x0F)]));
117 }
118 }
119 if escaped.len() > MAX_ESCAPED_SEGMENT_BYTES {
120 return Err(CoreError::InvalidInput {
121 reason: format!(
122 "workspace member segment escapes to {} bytes, over the {}-byte \
123 on-disk name cap: \"{segment}\"",
124 escaped.len(),
125 MAX_ESCAPED_SEGMENT_BYTES
126 ),
127 });
128 }
129 Ok(escaped)
130}
131
132/// The strict inverse of [`segment_escape`]: decode `%XX` (either hex
133/// case) and reproduce the original bytes exactly. Malformed `%`
134/// sequences (truncated, non-hex) refuse; a decoded result outside
135/// the escape's image — NUL, `/`, `.`/`..`/empty — also refuses, so a
136/// hand-written local path cannot smuggle path structure back
137/// through decode (strict-image enforcement, property-pinned).
138pub fn segment_unescape(segment: &str) -> Result<String, CoreError> {
139 let bytes = segment.as_bytes();
140 let mut decoded: Vec<u8> = Vec::with_capacity(bytes.len());
141 let mut index = 0;
142 while index < bytes.len() {
143 if bytes[index] != b'%' {
144 decoded.push(bytes[index]);
145 index += 1;
146 continue;
147 }
148 let (high, low) = match bytes.get(index + 1..index + 3) {
149 Some(pair) => (
150 (pair[0] as char).to_digit(16),
151 (pair[1] as char).to_digit(16),
152 ),
153 None => (None, None), // truncated — "%2" or a bare "%"
154 };
155 let (Some(high), Some(low)) = (high, low) else {
156 return Err(CoreError::InvalidInput {
157 reason: format!("malformed percent escape in workspace segment: \"{segment}\""),
158 });
159 };
160 decoded.push((high * 16 + low) as u8);
161 index += 3;
162 }
163 // Strict-image guards: segment_escape can never EMIT these.
164 if decoded.contains(&0) {
165 return Err(CoreError::InvalidInput {
166 reason: format!("decoded workspace segment carries NUL: \"{segment}\""),
167 });
168 }
169 if decoded.contains(&b'/') {
170 return Err(CoreError::InvalidInput {
171 reason: format!("decoded workspace segment carries a path separator: \"{segment}\""),
172 });
173 }
174 let decoded = String::from_utf8(decoded).map_err(|_| CoreError::InvalidInput {
175 reason: format!("decoded workspace segment is not valid UTF-8: \"{segment}\""),
176 })?;
177 if decoded.is_empty() || decoded == "." || decoded == ".." {
178 return Err(CoreError::InvalidInput {
179 reason: format!(
180 "decoded workspace segment is traversal-shaped or empty: \"{segment}\""
181 ),
182 });
183 }
184 Ok(decoded)
185}
186
187/// THE mapping: a gateway member user path → its local checkout
188/// path. Split on `/` (member-path segment semantics — a slash
189/// splits, never injects), escape each segment, join. Empty segments
190/// (`//`, leading/trailing slash) refuse through [`segment_escape`],
191/// and every refusal rides `invalid_input` naming the offending
192/// member path. Injectivity and the exact round-trip are
193/// machine-proven by the property suite (`tests/workspace_path_mapping.rs`).
194pub fn local_path_for(member_user_path: &str) -> Result<PathBuf, CoreError> {
195 let mut local = PathBuf::new();
196 for segment in member_user_path.split('/') {
197 let escaped = segment_escape(segment).map_err(|err| CoreError::InvalidInput {
198 reason: format!("workspace member path \"{member_user_path}\" is not mappable: {err}"),
199 })?;
200 local.push(escaped);
201 }
202 Ok(local)
203}
204
205/// Build the WHOLE checkout mapping at once — what 13-03's checkout
206/// calls exactly once, then records gateway↔local pairs into the
207/// manifest (never recomputed downstream — Pitfall W1).
208///
209/// Set-level injectivity enforcement on top of the per-member
210/// mapping:
211/// - an exact duplicate member (the same path listed twice) refuses —
212/// a caller bug, not something to absorb silently;
213/// - any two members whose local paths are equal under ASCII
214/// case-folding refuse, naming BOTH member paths (the APFS
215/// Pitfall-W1 class: escaping preserves case, but the filesystem
216/// folds it — `P13/A` and `p13/a` would silently overwrite at
217/// checkout and delete server-side at push).
218///
219/// Deterministic: members iterate sorted, so the resulting map and
220/// every error message are stable regardless of input order.
221pub fn build_mapping(members: &[String]) -> Result<BTreeMap<String, PathBuf>, CoreError> {
222 let mut sorted: Vec<&String> = members.iter().collect();
223 sorted.sort();
224 sorted.dedup();
225 if sorted.len() != members.len() {
226 let duplicate = members
227 .iter()
228 .find(|member| members.iter().filter(|other| *other == *member).count() > 1)
229 .expect("length mismatch proves a duplicate exists");
230 return Err(CoreError::InvalidInput {
231 reason: format!(
232 "workspace member list carries the exact duplicate \"{duplicate}\" — \
233 refusing rather than absorbing it"
234 ),
235 });
236 }
237
238 let mut mapping = BTreeMap::new();
239 // folded (lower-cased) rendered local path → the member that owns it.
240 let mut folded: BTreeMap<String, String> = BTreeMap::new();
241 for member in sorted {
242 let local = local_path_for(member)?;
243 let rendered = local.to_string_lossy().into_owned();
244 let key = rendered.to_ascii_lowercase();
245 if let Some(owner) = folded.get(&key) {
246 return Err(CoreError::InvalidInput {
247 reason: format!(
248 "workspace members \"{owner}\" and \"{member}\" collide on the same \
249 case-insensitive local path \"{rendered}\" — one would silently \
250 overwrite the other at checkout"
251 ),
252 });
253 }
254 folded.insert(key, member.clone());
255 mapping.insert(member.clone(), local);
256 }
257 Ok(mapping)
258}
259
260/// The member-content source abstraction (13-02 Task 3): the proven
261/// v1.0 member engine ([`crate::client::resources`], zip-bytes-only)
262/// speaks EITHER a gateway export zip OR a checked-out directory
263/// tree, with ONE implementation behind both — 13-03's checkout and
264/// 13-06's status compare build on this without touching proven code.
265///
266/// - [`MemberSource::Zip`] delegates VERBATIM to the existing
267/// resources.rs functions — descriptor-normalized enumeration/
268/// read/hash semantics stay single-source (no engine function is
269/// copied here).
270/// - [`MemberSource::Tree`] reads a checked-out tree through the
271/// RECORDED mapping with the SAME member-hash semantics: a
272/// member's hash is identical from either source for identical
273/// bytes (equivalence pinned by test). A `resource.json` member
274/// hashes its normalized descriptor exactly as the zip side does —
275/// the same helpers ([`resources::normalize_descriptor`],
276/// [`resources::fnv1a`], [`resources::FOLDER_DESCRIPTOR`]), never
277/// a fork.
278///
279/// Tree strictness (pinned here): the tree is MANIFEST-SCOPED. A
280/// mapped member missing from disk, or a local file not in the
281/// mapping, is an error from the source — 13-06's status handles
282/// unknown files at the action layer, where a human-facing verdict
283/// belongs.
284pub enum MemberSource {
285 /// A gateway project-export zip's raw bytes.
286 Zip(Vec<u8>),
287 /// A checked-out directory tree rooted at `root`, addressed
288 /// through the recorded checkout `mapping` (gateway member →
289 /// local path — [`build_mapping`]'s output, stored in the
290 /// manifest by 13-03).
291 Tree {
292 /// The checkout root the recorded local paths ride under.
293 root: PathBuf,
294 /// The recorded gateway-member → local-path pairs.
295 mapping: BTreeMap<String, PathBuf>,
296 },
297}
298
299impl MemberSource {
300 /// Every resource member's user path. Zip: the export walk in
301 /// member order (the proven [`resources::resource_members`]
302 /// semantics — `project.json`, directory entries, and
303 /// non-`resources`-shaped members skipped). Tree: the recorded
304 /// mapping's keys, sorted.
305 pub fn members(&self) -> Result<Vec<String>, CoreError> {
306 match self {
307 Self::Zip(bytes) => resources::resource_members(bytes),
308 Self::Tree { mapping, .. } => Ok(mapping.keys().cloned().collect()),
309 }
310 }
311
312 /// One member's bytes, verbatim. Zip: the proven
313 /// [`resources::read_member`] (missing → `not_found`). Tree:
314 /// `fs::read` at the mapped local path — a member outside the
315 /// recorded mapping or a missing file is `invalid_input` naming
316 /// the member (the tree is manifest-scoped, so "unmapped" is a
317 /// caller/manifest contract break, not a 404-shaped lookup miss).
318 pub fn read(&self, user_path: &str) -> Result<Vec<u8>, CoreError> {
319 match self {
320 Self::Zip(bytes) => resources::read_member(bytes, user_path),
321 Self::Tree { root, mapping } => {
322 let local = mapping
323 .get(user_path)
324 .ok_or_else(|| CoreError::InvalidInput {
325 reason: format!(
326 "workspace member \"{user_path}\" is not in the recorded \
327 checkout mapping"
328 ),
329 })?;
330 std::fs::read(root.join(local)).map_err(|err| CoreError::InvalidInput {
331 reason: format!(
332 "workspace member \"{user_path}\" cannot be read from the \
333 checkout tree: {err}"
334 ),
335 })
336 }
337 }
338 }
339
340 /// User path → FNV-1a digest for every member — the SAME
341 /// descriptor rule both sides: a basename `resource.json` hashes
342 /// its [`resources::normalize_descriptor`] output (the
343 /// lastModification volatility guard), everything else hashes raw
344 /// bytes. Zip: the proven [`resources::member_hashes`]. Tree:
345 /// reads the mapped files after a strictness walk — a mapped
346 /// member missing from disk, or an on-disk file absent from the
347 /// mapping, is `invalid_input` (manifest-scoped tree).
348 pub fn member_hashes(&self) -> Result<BTreeMap<String, u64>, CoreError> {
349 match self {
350 Self::Zip(bytes) => resources::member_hashes(bytes),
351 Self::Tree { root, mapping } => {
352 let found = scan_regular_files(root)?;
353 for (member, local) in mapping {
354 if !found.contains(local) {
355 return Err(CoreError::InvalidInput {
356 reason: format!(
357 "workspace member \"{member}\" is missing from the \
358 checkout tree (expected at \"{}\")",
359 local.to_string_lossy()
360 ),
361 });
362 }
363 }
364 for file in &found {
365 if !mapping.values().any(|local| local == file) {
366 return Err(CoreError::InvalidInput {
367 reason: format!(
368 "checkout tree contains local file \"{}\" that is not \
369 in the recorded mapping — the tree is manifest-scoped",
370 file.to_string_lossy()
371 ),
372 });
373 }
374 }
375 let mut hashes = BTreeMap::new();
376 for (member, local) in mapping {
377 let bytes =
378 std::fs::read(root.join(local)).map_err(|err| CoreError::InvalidInput {
379 reason: format!(
380 "workspace member \"{member}\" cannot be read from \
381 the checkout tree: {err}"
382 ),
383 })?;
384 let is_descriptor = local.file_name()
385 == Some(std::ffi::OsStr::new(resources::FOLDER_DESCRIPTOR));
386 let content = if is_descriptor {
387 resources::normalize_descriptor(&bytes).unwrap_or(bytes)
388 } else {
389 bytes
390 };
391 hashes.insert(member.clone(), resources::fnv1a(&content));
392 }
393 Ok(hashes)
394 }
395 }
396 }
397}
398
399/// Every regular file under `root`, as paths relative to it — the
400/// strictness walk backing [`MemberSource::Tree::member_hashes`].
401fn scan_regular_files(root: &std::path::Path) -> Result<BTreeSet<PathBuf>, CoreError> {
402 fn walk(
403 dir: &std::path::Path,
404 prefix: &std::path::Path,
405 found: &mut BTreeSet<PathBuf>,
406 ) -> std::io::Result<()> {
407 for entry in std::fs::read_dir(dir)? {
408 let entry = entry?;
409 let relative = prefix.join(entry.file_name());
410 let file_type = entry.file_type()?;
411 if file_type.is_dir() {
412 walk(&entry.path(), &relative, found)?;
413 } else if file_type.is_file() {
414 found.insert(relative);
415 }
416 }
417 Ok(())
418 }
419 let mut found = BTreeSet::new();
420 walk(root, std::path::Path::new(""), &mut found)
421 .map_err(|err| CoreError::Internal(format!("cannot walk checkout tree {root:?}: {err}")))?;
422 Ok(found)
423}