Skip to main content

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}