ignition_core/actions/resources.rs
1//! Resource actions (05-02 re-point): the surgical edit loop —
2//! list/get/put/delete ONE resource inside a project — riding
3//! project-export ZIP surgery instead of the nonexistent
4//! `/projects/{p}/resources/**` REST routes (the Phase 3 cross-phase
5//! defect, closed here; 05-RESEARCH §Resource Family Decision).
6//! Serde models OUT, no printing (ARCHITECTURE.md layering: the
7//! Phase-6 TUI rides this same layer).
8//!
9//! THE ORCHESTRATION (transport swapped, UX contract untouched):
10//! - list/get: [`GatewayApi::project_export_to_file`] to a temp zip
11//! → read the bytes → the pure helpers in
12//! [`crate::client::resources`] (`resource_members` / `read_member`)
13//! → the existing result shapes. A nonexistent project surfaces
14//! through export's existing 404 path (`not_found`, exit 6).
15//! - put: sniff the INPUT first (binary refuses before ANY network) →
16//! export → `replace_member` (append-when-absent = upsert) →
17//! [`GatewayApi::project_import`] with `overwrite=true` — put
18//! implicitly REPLACES the entire project, so the CLI guards it
19//! `--yes` like every destructive verb (05-02; the 03-03 unguarded
20//! put is superseded, README documents the consequence).
21//! - delete: same surgery with `remove_member` (missing member →
22//! `not_found`) → import overwrite.
23//!
24//! Perf honesty (research's accepted trade): every resource op
25//! round-trips the WHOLE project zip. Rigs and dev projects are
26//! small; the alternative was the family not working at all.
27//!
28//! The heart that survives from 03-03 is [`classify_content`] — the
29//! classify/HTML-sniffer discipline INVERTED: it sniffs resource
30//! CONTENT to pick the wire representation. The order is the
31//! contract (Pitfall 7):
32//! 1. NUL byte in the first 8 KiB → Binary (refuse — a `data.bin`
33//! resource must NEVER round-trip through the JSON/text loop;
34//! export/import owns binary resources);
35//! 2. valid UTF-8 that JSON-parses → Json;
36//! 3. valid UTF-8 → Text;
37//! 4. invalid UTF-8 (no NUL in the head) → Binary all the same.
38//!
39//! The get result keeps the family's stable agent shape
40//! (`{project, path, content_kind, content}` — all keys always
41//! present; `content` is the parsed JSON value or the text as a JSON
42//! string); a Binary get refuses with [`CoreError::ResourceBinary`]
43//! before any result is built — now sniffed from the zip MEMBER
44//! bytes.
45
46use serde::Serialize;
47
48use crate::client::GatewayApi;
49use crate::client::resources::{
50 ResourceEntry, read_member, remove_member, replace_member, resource_members,
51};
52use crate::error::CoreError;
53
54/// How far into a resource body the binary heuristic looks — real
55/// JSON/text payloads never carry NUL anywhere, and 8 KiB catches
56/// every `data.bin`-class resource's magic long before the tail.
57const BINARY_SNIFF_WINDOW: usize = 8 * 1024;
58
59/// The sniffed content kind — see the module docs for the order.
60#[derive(Debug, Clone, PartialEq)]
61pub enum ContentKind {
62 /// Valid UTF-8 that JSON-parsed (the parsed value rides along).
63 Json(serde_json::Value),
64 /// Valid UTF-8 that did not JSON-parse.
65 Text(String),
66 /// NUL in the first 8 KiB, or non-UTF-8 — refused downstream.
67 Binary,
68}
69
70/// Sniff resource bytes: NUL-in-first-8KiB → Binary; UTF-8 +
71/// JSON-parse → Json; UTF-8 → Text; else Binary. Pure — testable
72/// without any gateway.
73pub fn classify_content(bytes: &[u8]) -> ContentKind {
74 let head = &bytes[..bytes.len().min(BINARY_SNIFF_WINDOW)];
75 if head.contains(&0) {
76 return ContentKind::Binary;
77 }
78 match std::str::from_utf8(bytes) {
79 Ok(text) => match serde_json::from_str::<serde_json::Value>(text) {
80 Ok(value) => ContentKind::Json(value),
81 Err(_) => ContentKind::Text(text.to_string()),
82 },
83 Err(_) => ContentKind::Binary,
84 }
85}
86
87impl ContentKind {
88 /// The stable agent-facing label ("json" | "text" — Binary never
89 /// reaches a result; it refuses).
90 fn label(&self) -> &'static str {
91 match self {
92 Self::Json(_) => "json",
93 Self::Text(_) => "text",
94 Self::Binary => "binary",
95 }
96 }
97}
98
99/// `ign resource list` output model: the entries, one path per line
100/// in human mode (surgery-sourced entries carry only `path`).
101#[derive(Debug, Serialize)]
102pub struct ResourcesResult {
103 /// The project's resources (member paths in zip order).
104 pub resources: Vec<ResourceEntry>,
105}
106
107/// `ign resource get` output model — the stable agent shape:
108/// `{project, path, content_kind, content}` (a Binary get refuses
109/// before this is built; `content` is the parsed JSON value or the
110/// text as a JSON string).
111#[derive(Debug, Serialize)]
112pub struct ResourceGetResult {
113 /// The project the resource lives in.
114 pub project: String,
115 /// The resource path.
116 pub path: String,
117 /// "json" | "text" — the sniffed kind.
118 pub content_kind: String,
119 /// The content: parsed JSON (any shape) or the UTF-8 text.
120 pub content: serde_json::Value,
121}
122
123/// `ign resource put` output model.
124#[derive(Debug, Serialize)]
125pub struct ResourcePutResult {
126 /// The project the resource landed in.
127 pub project: String,
128 /// The resource path (created if absent — upsert).
129 pub path: String,
130 /// "json" | "text" — the sniffed kind that rode the surgery.
131 pub content_kind: String,
132}
133
134/// `ign resource delete` output model.
135#[derive(Debug, Serialize)]
136pub struct ResourceDeleteResult {
137 /// The deleted resource's path.
138 pub deleted: String,
139}
140
141/// The shared first half of every resource op: stream the project
142/// export into a unique temp file, then read the bytes back for
143/// in-memory surgery. A nonexistent project fails inside export's
144/// existing classification (404 → `not_found`, exit 6). The
145/// `tempfile` dependency (promoted from dev — already in the
146/// workspace graph) owns uniqueness and cleanup-on-drop.
147///
148/// 07-01: promoted pub — the cross-gateway diff/sync actions ride the
149/// SAME export-to-bytes seam (two clients, one helper).
150pub async fn export_zip_bytes(api: &dyn GatewayApi, project: &str) -> Result<Vec<u8>, CoreError> {
151 let temp = tempfile::NamedTempFile::new()
152 .map_err(|err| CoreError::Internal(format!("cannot create temp export file: {err}")))?;
153 api.project_export_to_file(project, temp.path()).await?;
154 tokio::fs::read(temp.path()).await.map_err(|err| {
155 CoreError::Internal(format!(
156 "cannot read back export {}: {err}",
157 temp.path().display()
158 ))
159 })
160}
161
162/// `ign resource list PROJECT [--prefix P]` — export → member list.
163/// The prefix filters CLIENT-SIDE now (member paths, `starts_with`):
164/// the old server-side `path` query param rode routes that never
165/// existed; the UX contract (one path per line) is unchanged.
166pub async fn resources_list(
167 api: &dyn GatewayApi,
168 project: &str,
169 prefix: Option<&str>,
170) -> Result<ResourcesResult, CoreError> {
171 let zip = export_zip_bytes(api, project).await?;
172 let resources = resource_members(&zip)?
173 .into_iter()
174 .filter(|path| prefix.is_none_or(|prefix| path.starts_with(prefix)))
175 .map(|path| ResourceEntry {
176 path: Some(path),
177 extra: Default::default(),
178 })
179 .collect();
180 Ok(ResourcesResult { resources })
181}
182
183/// `ign resource get PROJECT PATH` — export → member read → sniff →
184/// the stable shape. Binary (now sniffed from the zip member bytes)
185/// → [`CoreError::ResourceBinary`] (exit 6): a `data.bin`-class
186/// resource must never be corrupted through the JSON loop (Pitfall
187/// 7). A missing member is `not_found` from the surgery helper.
188pub async fn resource_get(
189 api: &dyn GatewayApi,
190 project: &str,
191 path: &str,
192) -> Result<ResourceGetResult, CoreError> {
193 let zip = export_zip_bytes(api, project).await?;
194 let bytes = read_member(&zip, path)?;
195 match classify_content(&bytes) {
196 ContentKind::Json(value) => Ok(ResourceGetResult {
197 project: project.to_string(),
198 path: path.to_string(),
199 content_kind: "json".to_string(),
200 content: value,
201 }),
202 ContentKind::Text(text) => Ok(ResourceGetResult {
203 project: project.to_string(),
204 path: path.to_string(),
205 content_kind: "text".to_string(),
206 content: serde_json::Value::String(text),
207 }),
208 ContentKind::Binary => Err(CoreError::ResourceBinary {
209 path: path.to_string(),
210 endpoint: None,
211 }),
212 }
213}
214
215/// `ign resource put PROJECT PATH --file F|-` — sniff the INPUT
216/// first: Binary refuses (exit 6) before ANY network I/O. Then the
217/// surgery loop: export → `replace_member` (append-when-absent =
218/// upsert) → import `overwrite=true`. The import REPLACES the entire
219/// project — replace-not-merge wipes concurrent Designer edits — so
220/// the CLI dispatch guards this verb `--yes` BEFORE resolution (the
221/// 05-02 destructive-verb set; 03-03's unguarded put is superseded).
222pub async fn resource_put(
223 api: &dyn GatewayApi,
224 project: &str,
225 path: &str,
226 input: Vec<u8>,
227) -> Result<ResourcePutResult, CoreError> {
228 let kind = classify_content(&input);
229 if matches!(kind, ContentKind::Binary) {
230 return Err(CoreError::ResourceBinary {
231 path: path.to_string(),
232 endpoint: None,
233 });
234 }
235 let zip = export_zip_bytes(api, project).await?;
236 let surgical = replace_member(&zip, path, &input)?;
237 api.project_import(project, surgical, true).await?;
238 Ok(ResourcePutResult {
239 project: project.to_string(),
240 path: path.to_string(),
241 content_kind: kind.label().to_string(),
242 })
243}
244
245/// `ign resource delete PROJECT PATH` — export → `remove_member`
246/// (missing member → `not_found`) → import overwrite. The `--yes`
247/// guard belongs to the CLI CALLER (it refuses pre-resolution, the
248/// LOCKED 02-03 shape) — this arm only runs once confirmed.
249pub async fn resource_delete(
250 api: &dyn GatewayApi,
251 project: &str,
252 path: &str,
253) -> Result<ResourceDeleteResult, CoreError> {
254 let zip = export_zip_bytes(api, project).await?;
255 let surgical = remove_member(&zip, path)?;
256 api.project_import(project, surgical, true).await?;
257 Ok(ResourceDeleteResult {
258 deleted: path.to_string(),
259 })
260}
261
262#[cfg(test)]
263mod tests {
264 use super::{ContentKind, classify_content};
265
266 /// The sniffer's three outcomes, each pinned: JSON parses (value
267 /// preserved), non-JSON UTF-8 stays text, NUL-in-head is binary.
268 #[test]
269 fn classify_content_sniffs_all_three_kinds() {
270 assert_eq!(
271 classify_content(br#"{"scope":"G","code":"print('hi')"}"#),
272 ContentKind::Json(serde_json::json!({"scope":"G","code":"print('hi')"})),
273 "UTF-8 that JSON-parses → Json (value preserved)"
274 );
275 assert_eq!(
276 classify_content(b"print('just a script')\n"),
277 ContentKind::Text("print('just a script')\n".to_string()),
278 "UTF-8 that does not parse → Text"
279 );
280 assert_eq!(
281 classify_content(&[0x00, 0x50, 0x4B, 0x03]),
282 ContentKind::Binary,
283 "NUL in the head → Binary (data.bin class)"
284 );
285 assert_eq!(
286 classify_content(&[0xFF, 0xFE, 0x00, 0x01]),
287 ContentKind::Binary,
288 "non-UTF-8 → Binary too (no honest textual form)"
289 );
290 }
291
292 /// The NUL window's honest boundary: a NUL PAST the first 8 KiB
293 /// in otherwise-valid UTF-8 is NOT caught by the head heuristic —
294 /// and `from_utf8` accepts NUL as text (0x00 is valid UTF-8), so
295 /// it classifies Text. That is the documented trade: real
296 /// data.bin-class resources carry binary magic well inside the
297 /// window; only adversarial input hides a NUL past it.
298 #[test]
299 fn classify_content_nul_past_window_is_text() {
300 let mut bytes = vec![b'x'; super::BINARY_SNIFF_WINDOW + 64];
301 bytes[super::BINARY_SNIFF_WINDOW + 32] = 0;
302 assert_eq!(
303 classify_content(&bytes),
304 ContentKind::Text(String::from_utf8(bytes.clone()).expect("NUL is valid UTF-8")),
305 "a lone NUL past the 8 KiB window in UTF-8 input classifies Text \
306 (the heuristic's documented boundary)"
307 );
308 // …while the SAME NUL inside the window refuses.
309 bytes[16] = 0;
310 assert_eq!(classify_content(&bytes), ContentKind::Binary);
311 }
312
313 /// The labels the results ride on.
314 #[test]
315 fn content_kind_labels() {
316 assert_eq!(ContentKind::Json(serde_json::json!(1)).label(), "json");
317 assert_eq!(ContentKind::Text(String::new()).label(), "text");
318 assert_eq!(ContentKind::Binary.label(), "binary");
319 }
320}