1use std::collections::HashSet;
8use std::fmt;
9use std::io;
10use std::path::{Component, Path, PathBuf};
11
12use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
13use glob::Pattern;
14use runmat_builtins::{
15 BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
16 BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
17 Tensor, Value,
18};
19use runmat_filesystem as vfs;
20use runmat_macros::runtime_builtin;
21use sha2::{Digest, Sha256};
22
23use crate::builtins::common::fs::expand_user_path;
24use crate::builtins::common::path_search::{find_file_with_extensions, path_is_directory};
25use crate::builtins::common::spec::{
26 BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
27 ReductionNaN, ResidencyPolicy, ShapeRequirements,
28};
29use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
30
31const BUILTIN_NAME: &str = "pcode";
32const RUNMAT_PCODE_MAGIC: &str = "RUNMAT-PCODE-V1";
33const SOURCE_EXTENSIONS: &[&str] = &[".m"];
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum PcodeAlgorithm {
37 R2007b,
38 R2022a,
39}
40
41impl PcodeAlgorithm {
42 fn label(self) -> &'static str {
43 match self {
44 PcodeAlgorithm::R2007b => "R2007b",
45 PcodeAlgorithm::R2022a => "R2022a",
46 }
47 }
48
49 fn from_label(label: &str) -> Option<Self> {
50 match label {
51 "R2007b" => Some(PcodeAlgorithm::R2007b),
52 "R2022a" => Some(PcodeAlgorithm::R2022a),
53 _ => None,
54 }
55 }
56
57 fn key(self) -> &'static [u8] {
58 match self {
59 PcodeAlgorithm::R2007b => b"runmat-pcode-r2007b-v1",
60 PcodeAlgorithm::R2022a => b"runmat-pcode-r2022a-v1",
61 }
62 }
63}
64
65const INPUT_ITEM: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
66 name: "item",
67 ty: BuiltinParamType::StringScalar,
68 arity: BuiltinParamArity::Required,
69 default: None,
70 description: "MATLAB source file, source name, folder, or wildcard pattern to encode.",
71}];
72
73const INPUT_ITEM_VERSION: [BuiltinParamDescriptor; 2] = [
74 BuiltinParamDescriptor {
75 name: "item",
76 ty: BuiltinParamType::StringScalar,
77 arity: BuiltinParamArity::Required,
78 default: None,
79 description: "MATLAB source file, source name, folder, or wildcard pattern to encode.",
80 },
81 BuiltinParamDescriptor {
82 name: "version",
83 ty: BuiltinParamType::StringScalar,
84 arity: BuiltinParamArity::Optional,
85 default: Some("-R2007b"),
86 description: "Obfuscation compatibility selector, either -R2007b or -R2022a.",
87 },
88];
89
90const INPUT_ITEMS_INPLACE: [BuiltinParamDescriptor; 3] = [
91 BuiltinParamDescriptor {
92 name: "item1",
93 ty: BuiltinParamType::StringScalar,
94 arity: BuiltinParamArity::Required,
95 default: None,
96 description: "First MATLAB source file, source name, folder, or wildcard pattern.",
97 },
98 BuiltinParamDescriptor {
99 name: "itemN",
100 ty: BuiltinParamType::StringScalar,
101 arity: BuiltinParamArity::Variadic,
102 default: None,
103 description: "Additional source files, source names, folders, or wildcard patterns.",
104 },
105 BuiltinParamDescriptor {
106 name: "inplace",
107 ty: BuiltinParamType::StringScalar,
108 arity: BuiltinParamArity::Optional,
109 default: None,
110 description: "Use -inplace to write each P-code file beside its source file.",
111 },
112];
113
114const SIGNATURES: [BuiltinSignatureDescriptor; 3] = [
115 BuiltinSignatureDescriptor {
116 label: "pcode(item)",
117 inputs: &INPUT_ITEM,
118 outputs: &[],
119 },
120 BuiltinSignatureDescriptor {
121 label: "pcode(item, version)",
122 inputs: &INPUT_ITEM_VERSION,
123 outputs: &[],
124 },
125 BuiltinSignatureDescriptor {
126 label: "pcode(item1, item2, ..., itemN, \"-inplace\")",
127 inputs: &INPUT_ITEMS_INPLACE,
128 outputs: &[],
129 },
130];
131
132const ERROR_NOT_ENOUGH_INPUTS: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
133 code: "RM.PCODE.NOT_ENOUGH_INPUTS",
134 identifier: Some("RunMat:pcode:NotEnoughInputs"),
135 when: "No source item is provided.",
136 message: "pcode: not enough input arguments",
137};
138const ERROR_TOO_MANY_OUTPUTS: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
139 code: "RM.PCODE.TOO_MANY_OUTPUTS",
140 identifier: Some("RunMat:pcode:TooManyOutputs"),
141 when: "`pcode` is called with requested output arguments.",
142 message: "pcode: too many output arguments",
143};
144const ERROR_ARG_TYPE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
145 code: "RM.PCODE.ARG_TYPE",
146 identifier: Some("RunMat:pcode:InvalidArgument"),
147 when: "An argument is not a character row, string scalar, or scalar string array.",
148 message: "pcode: arguments must be character vectors or string scalars",
149};
150const ERROR_EMPTY_ITEM: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
151 code: "RM.PCODE.EMPTY_ITEM",
152 identifier: Some("RunMat:pcode:EmptyItem"),
153 when: "A source item is empty.",
154 message: "pcode: source item must not be empty",
155};
156const ERROR_INVALID_OPTION: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
157 code: "RM.PCODE.INVALID_OPTION",
158 identifier: Some("RunMat:pcode:InvalidOption"),
159 when: "An option is not -inplace, -R2007b, or -R2022a.",
160 message: "pcode: invalid option",
161};
162const ERROR_PATH_RESOLVE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
163 code: "RM.PCODE.PATH_RESOLVE",
164 identifier: Some("RunMat:pcode:PathResolveFailed"),
165 when: "RunMat cannot resolve a source item or destination path.",
166 message: "pcode: failed to resolve path",
167};
168const ERROR_FILE_NOT_FOUND: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
169 code: "RM.PCODE.FILE_NOT_FOUND",
170 identifier: Some("RunMat:pcode:FileNotFound"),
171 when: "No MATLAB `.m` source file matches the item.",
172 message: "pcode: source file not found",
173};
174const ERROR_UNSUPPORTED_EXTENSION: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
175 code: "RM.PCODE.UNSUPPORTED_EXTENSION",
176 identifier: Some("RunMat:pcode:UnsupportedExtension"),
177 when: "The source item names an unsupported file type, including `.mlx` live scripts.",
178 message: "pcode: only MATLAB .m source files are supported",
179};
180const ERROR_FILE_READ: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
181 code: "RM.PCODE.FILE_READ",
182 identifier: Some("RunMat:pcode:FileReadFailed"),
183 when: "A source file cannot be read as text.",
184 message: "pcode: failed to read source file",
185};
186const ERROR_FILE_WRITE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
187 code: "RM.PCODE.FILE_WRITE",
188 identifier: Some("RunMat:pcode:FileWriteFailed"),
189 when: "The destination `.p` file cannot be written.",
190 message: "pcode: failed to write P-code file",
191};
192const ERROR_INVALID_PCODE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
193 code: "RM.PCODE.INVALID_PCODE",
194 identifier: Some("RunMat:pcode:InvalidPcode"),
195 when: "A `.p` file is not a RunMat-generated P-code container or fails integrity checks.",
196 message: "pcode: invalid or unsupported P-code file",
197};
198
199const ERRORS: [BuiltinErrorDescriptor; 11] = [
200 ERROR_NOT_ENOUGH_INPUTS,
201 ERROR_TOO_MANY_OUTPUTS,
202 ERROR_ARG_TYPE,
203 ERROR_EMPTY_ITEM,
204 ERROR_INVALID_OPTION,
205 ERROR_PATH_RESOLVE,
206 ERROR_FILE_NOT_FOUND,
207 ERROR_UNSUPPORTED_EXTENSION,
208 ERROR_FILE_READ,
209 ERROR_FILE_WRITE,
210 ERROR_INVALID_PCODE,
211];
212
213pub const PCODE_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
214 signatures: &SIGNATURES,
215 output_mode: BuiltinOutputMode::Fixed,
216 completion_policy: BuiltinCompletionPolicy::Public,
217 errors: &ERRORS,
218};
219
220#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::io::repl_fs::pcode")]
221pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
222 name: "pcode",
223 op_kind: GpuOpKind::Custom("io-pcode"),
224 supported_precisions: &[],
225 broadcast: BroadcastSemantics::None,
226 provider_hooks: &[],
227 constant_strategy: ConstantStrategy::InlineLiteral,
228 residency: ResidencyPolicy::GatherImmediately,
229 nan_mode: ReductionNaN::Include,
230 two_pass_threshold: None,
231 workgroup_size: None,
232 accepts_nan_mode: false,
233 notes: "Host-only source packaging builtin. GPU-resident string arguments are gathered before filesystem access; no numeric provider kernels are applicable.",
234};
235
236#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::io::repl_fs::pcode")]
237pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
238 name: "pcode",
239 shape: ShapeRequirements::Any,
240 constant_strategy: ConstantStrategy::InlineLiteral,
241 elementwise: None,
242 reduction: None,
243 emits_nan: false,
244 notes: "P-code generation performs host filesystem side effects and is a fusion barrier.",
245};
246
247#[derive(Debug, Clone)]
248pub struct PcodeResult {
249 pub generated: Vec<PathBuf>,
250}
251
252#[derive(Debug)]
253pub enum PcodeSourceReadError {
254 Io(io::Error),
255 InvalidPcode(String),
256}
257
258impl fmt::Display for PcodeSourceReadError {
259 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
260 match self {
261 PcodeSourceReadError::Io(err) => write!(f, "{err}"),
262 PcodeSourceReadError::InvalidPcode(err) => write!(f, "{err}"),
263 }
264 }
265}
266
267impl std::error::Error for PcodeSourceReadError {
268 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
269 match self {
270 PcodeSourceReadError::Io(err) => Some(err),
271 PcodeSourceReadError::InvalidPcode(_) => None,
272 }
273 }
274}
275
276impl From<io::Error> for PcodeSourceReadError {
277 fn from(err: io::Error) -> Self {
278 PcodeSourceReadError::Io(err)
279 }
280}
281
282#[derive(Debug, Clone)]
283struct PcodeOptions {
284 algorithm: PcodeAlgorithm,
285 inplace: bool,
286 items: Vec<String>,
287}
288
289#[runtime_builtin(
290 name = "pcode",
291 category = "io/repl_fs",
292 summary = "Generate RunMat-executable content-obscured P-code files from MATLAB source.",
293 keywords = "pcode,p-code,source,obfuscate,file,folder,inplace",
294 accel = "cpu",
295 sink = true,
296 suppress_auto_output = true,
297 type_resolver(crate::builtins::io::type_resolvers::pcode_type),
298 descriptor(crate::builtins::io::repl_fs::pcode::PCODE_DESCRIPTOR),
299 builtin_path = "crate::builtins::io::repl_fs::pcode"
300)]
301async fn pcode_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
302 evaluate(&args).await?;
303 Ok(empty_array())
304}
305
306pub async fn evaluate(args: &[Value]) -> BuiltinResult<PcodeResult> {
307 if requested_output_count() > 0 {
308 return Err(pcode_error(&ERROR_TOO_MANY_OUTPUTS));
309 }
310 let options = parse_options(args).await?;
311 let sources = resolve_sources(&options.items).await?;
312 if sources.is_empty() {
313 return Err(pcode_error(&ERROR_FILE_NOT_FOUND));
314 }
315
316 let mut generated = Vec::with_capacity(sources.len());
317 for source in sources {
318 let text = vfs::read_to_string_async(&source).await.map_err(|err| {
319 pcode_error_with_detail(&ERROR_FILE_READ, format!("{} ({err})", source.display()))
320 })?;
321 let destination = destination_for_source(&source, options.inplace)?;
322 if let Some(parent) = destination.parent() {
323 if !parent.as_os_str().is_empty() {
324 vfs::create_dir_all_async(parent).await.map_err(|err| {
325 pcode_error_with_detail(
326 &ERROR_FILE_WRITE,
327 format!("{} ({err})", parent.display()),
328 )
329 })?;
330 }
331 }
332 let source_name = source.to_string_lossy();
333 let encoded = encode_pcode_source(&text, &source_name, options.algorithm);
334 vfs::write_async(&destination, encoded.as_bytes())
335 .await
336 .map_err(|err| {
337 pcode_error_with_detail(
338 &ERROR_FILE_WRITE,
339 format!("{} ({err})", destination.display()),
340 )
341 })?;
342 generated.push(destination);
343 }
344 Ok(PcodeResult { generated })
345}
346
347pub async fn prefer_pcode_source_path(path: &Path) -> PathBuf {
348 if path
349 .extension()
350 .and_then(|ext| ext.to_str())
351 .is_some_and(|ext| ext.eq_ignore_ascii_case("m"))
352 {
353 let pcode_path = path.with_extension("p");
354 if let Ok(metadata) = vfs::metadata_async(&pcode_path).await {
355 if metadata.is_file() {
356 return pcode_path;
357 }
358 }
359 }
360 path.to_path_buf()
361}
362
363pub async fn read_source_text_async(path: &Path) -> Result<String, PcodeSourceReadError> {
364 if path
365 .extension()
366 .and_then(|ext| ext.to_str())
367 .is_some_and(|ext| ext.eq_ignore_ascii_case("p"))
368 {
369 let bytes = vfs::read_async(path).await?;
370 decode_pcode_bytes(&bytes).map_err(PcodeSourceReadError::InvalidPcode)
371 } else {
372 vfs::read_to_string_async(path)
373 .await
374 .map_err(PcodeSourceReadError::Io)
375 }
376}
377
378pub fn encode_pcode_source(source: &str, source_name: &str, algorithm: PcodeAlgorithm) -> String {
379 let payload = transform_payload(source.as_bytes(), algorithm);
380 let source_name = BASE64.encode(source_name.as_bytes());
381 format!(
382 "{RUNMAT_PCODE_MAGIC}\nalgorithm:{}\nsource:{}\nsha256:{}\npayload:{}\n",
383 algorithm.label(),
384 source_name,
385 sha256_hex(source.as_bytes()),
386 BASE64.encode(payload)
387 )
388}
389
390pub fn decode_pcode_bytes(bytes: &[u8]) -> Result<String, String> {
391 let text = std::str::from_utf8(bytes).map_err(|_| "P-code file is not UTF-8".to_string())?;
392 let mut lines = text.lines();
393 if lines.next() != Some(RUNMAT_PCODE_MAGIC) {
394 return Err("not a RunMat-generated P-code file".to_string());
395 }
396
397 let mut algorithm = None;
398 let mut expected_hash = None;
399 let mut payload = None;
400 for line in lines {
401 if let Some(rest) = line.strip_prefix("algorithm:") {
402 algorithm = PcodeAlgorithm::from_label(rest);
403 } else if let Some(rest) = line.strip_prefix("sha256:") {
404 expected_hash = Some(rest.to_string());
405 } else if let Some(rest) = line.strip_prefix("payload:") {
406 payload = Some(rest.to_string());
407 }
408 }
409
410 let algorithm = algorithm.ok_or_else(|| "P-code algorithm is missing".to_string())?;
411 let expected_hash = expected_hash.ok_or_else(|| "P-code hash is missing".to_string())?;
412 let payload = payload.ok_or_else(|| "P-code payload is missing".to_string())?;
413 let obfuscated = BASE64
414 .decode(payload.as_bytes())
415 .map_err(|err| format!("P-code payload is invalid base64: {err}"))?;
416 let decoded = transform_payload(&obfuscated, algorithm);
417 if sha256_hex(&decoded) != expected_hash {
418 return Err("P-code payload hash mismatch".to_string());
419 }
420 String::from_utf8(decoded).map_err(|_| "P-code payload is not UTF-8 source".to_string())
421}
422
423async fn parse_options(args: &[Value]) -> BuiltinResult<PcodeOptions> {
424 if args.is_empty() {
425 return Err(pcode_error(&ERROR_NOT_ENOUGH_INPUTS));
426 }
427 let mut options = PcodeOptions {
428 algorithm: PcodeAlgorithm::R2007b,
429 inplace: false,
430 items: Vec::new(),
431 };
432
433 for arg in args {
434 let value = gather_if_needed_async(arg)
435 .await
436 .map_err(map_control_flow)?;
437 let text = value_to_string_scalar(&value).ok_or_else(|| pcode_error(&ERROR_ARG_TYPE))?;
438 if text.is_empty() {
439 return Err(pcode_error(&ERROR_EMPTY_ITEM));
440 }
441 let lowered = text.to_ascii_lowercase();
442 match lowered.as_str() {
443 "-inplace" => options.inplace = true,
444 "-r2007b" => options.algorithm = PcodeAlgorithm::R2007b,
445 "-r2022a" => options.algorithm = PcodeAlgorithm::R2022a,
446 option if option.starts_with('-') => {
447 return Err(pcode_error_with_detail(&ERROR_INVALID_OPTION, text));
448 }
449 _ => options.items.push(text),
450 }
451 }
452
453 if options.items.is_empty() {
454 return Err(pcode_error(&ERROR_NOT_ENOUGH_INPUTS));
455 }
456 Ok(options)
457}
458
459async fn resolve_sources(items: &[String]) -> BuiltinResult<Vec<PathBuf>> {
460 let mut out = Vec::new();
461 let mut seen = HashSet::new();
462 for item in items {
463 for source in resolve_item(item).await? {
464 let key = vfs::canonicalize_async(&source)
465 .await
466 .unwrap_or_else(|_| source.clone());
467 if seen.insert(key) {
468 out.push(source);
469 }
470 }
471 }
472 Ok(out)
473}
474
475async fn resolve_item(item: &str) -> BuiltinResult<Vec<PathBuf>> {
476 if item_contains_wildcards(item) {
477 return resolve_wildcard_item(item).await;
478 }
479 let expanded = expand_user_path(item, BUILTIN_NAME)
480 .map(PathBuf::from)
481 .map_err(|err| pcode_error_with_detail(&ERROR_PATH_RESOLVE, err))?;
482 if path_is_directory(&expanded).await {
483 return resolve_folder_item(&expanded).await;
484 }
485 if has_unsupported_extension(&expanded) {
486 return Err(pcode_error_with_detail(&ERROR_UNSUPPORTED_EXTENSION, item));
487 }
488 let Some(path) = find_file_with_extensions(item, SOURCE_EXTENSIONS, BUILTIN_NAME)
489 .await
490 .map_err(|err| pcode_error_with_detail(&ERROR_PATH_RESOLVE, err))?
491 else {
492 return Err(pcode_error_with_detail(&ERROR_FILE_NOT_FOUND, item));
493 };
494 if !is_m_file(&path) {
495 return Err(pcode_error_with_detail(
496 &ERROR_UNSUPPORTED_EXTENSION,
497 path.display().to_string(),
498 ));
499 }
500 Ok(vec![path])
501}
502
503async fn resolve_folder_item(folder: &Path) -> BuiltinResult<Vec<PathBuf>> {
504 let mut sources = Vec::new();
505 let entries = vfs::read_dir_async(folder).await.map_err(|err| {
506 pcode_error_with_detail(&ERROR_PATH_RESOLVE, format!("{} ({err})", folder.display()))
507 })?;
508 for entry in entries {
509 if !entry.is_dir() && is_m_file(entry.path()) {
510 sources.push(entry.path().to_path_buf());
511 }
512 }
513 sources.sort();
514 Ok(sources)
515}
516
517async fn resolve_wildcard_item(pattern_text: &str) -> BuiltinResult<Vec<PathBuf>> {
518 let expanded = expand_user_path(pattern_text, BUILTIN_NAME)
519 .map(PathBuf::from)
520 .map_err(|err| pcode_error_with_detail(&ERROR_PATH_RESOLVE, err))?;
521 let parent = expanded.parent().filter(|p| !p.as_os_str().is_empty());
522 let dir = parent
523 .map(Path::to_path_buf)
524 .unwrap_or_else(|| PathBuf::from("."));
525 let file_pattern = expanded
526 .file_name()
527 .and_then(|name| name.to_str())
528 .ok_or_else(|| pcode_error_with_detail(&ERROR_PATH_RESOLVE, pattern_text))?;
529 let pattern = Pattern::new(file_pattern)
530 .map_err(|err| pcode_error_with_detail(&ERROR_PATH_RESOLVE, err.to_string()))?;
531 let entries = vfs::read_dir_async(&dir).await.map_err(|err| {
532 pcode_error_with_detail(&ERROR_PATH_RESOLVE, format!("{} ({err})", dir.display()))
533 })?;
534 let mut sources = Vec::new();
535 for entry in entries {
536 let Some(name) = entry.file_name().to_str() else {
537 continue;
538 };
539 if !entry.is_dir() && is_m_file(entry.path()) && pattern.matches(name) {
540 sources.push(entry.path().to_path_buf());
541 }
542 }
543 sources.sort();
544 if sources.is_empty() {
545 return Err(pcode_error_with_detail(&ERROR_FILE_NOT_FOUND, pattern_text));
546 }
547 Ok(sources)
548}
549
550fn destination_for_source(source: &Path, inplace: bool) -> BuiltinResult<PathBuf> {
551 let mut filename = source
552 .file_name()
553 .ok_or_else(|| pcode_error_with_detail(&ERROR_PATH_RESOLVE, source.display().to_string()))?
554 .to_os_string();
555 filename = PathBuf::from(filename).with_extension("p").into_os_string();
556
557 if inplace {
558 return Ok(source.with_file_name(filename));
559 }
560
561 let mut destination = vfs::current_dir()
562 .map_err(|err| pcode_error_with_detail(&ERROR_PATH_RESOLVE, err.to_string()))?;
563 for component in package_or_class_suffix(source.parent()) {
564 destination.push(component);
565 }
566 destination.push(filename);
567 Ok(destination)
568}
569
570fn package_or_class_suffix(parent: Option<&Path>) -> Vec<String> {
571 let Some(parent) = parent else {
572 return Vec::new();
573 };
574 let mut current_run = Vec::new();
575 for component in parent.components() {
576 let Component::Normal(part) = component else {
577 current_run.clear();
578 continue;
579 };
580 let Some(part) = part.to_str() else {
581 current_run.clear();
582 continue;
583 };
584 if part.starts_with('+') || part.starts_with('@') {
585 current_run.push(part.to_string());
586 } else {
587 current_run.clear();
588 }
589 }
590 current_run
591}
592
593fn item_contains_wildcards(item: &str) -> bool {
594 item.contains('*') || item.contains('?') || item.contains('[')
595}
596
597fn has_unsupported_extension(path: &Path) -> bool {
598 let Some(extension) = path.extension().and_then(|ext| ext.to_str()) else {
599 return false;
600 };
601 !extension.eq_ignore_ascii_case("m")
602}
603
604fn is_m_file(path: &Path) -> bool {
605 path.extension()
606 .and_then(|ext| ext.to_str())
607 .is_some_and(|ext| ext.eq_ignore_ascii_case("m"))
608}
609
610fn value_to_string_scalar(value: &Value) -> Option<String> {
611 match value {
612 Value::String(text) => Some(text.clone()),
613 Value::CharArray(array) if array.rows == 1 => Some(array.data.iter().collect()),
614 Value::StringArray(array) if array.data.len() == 1 => Some(array.data[0].clone()),
615 _ => None,
616 }
617}
618
619fn transform_payload(bytes: &[u8], algorithm: PcodeAlgorithm) -> Vec<u8> {
620 let key = algorithm.key();
621 bytes
622 .iter()
623 .enumerate()
624 .map(|(index, byte)| {
625 let salt = match algorithm {
626 PcodeAlgorithm::R2007b => index.wrapping_mul(31) as u8,
627 PcodeAlgorithm::R2022a => index.wrapping_mul(73).rotate_left(1) as u8,
628 };
629 byte ^ key[index % key.len()] ^ salt
630 })
631 .collect()
632}
633
634fn sha256_hex(bytes: &[u8]) -> String {
635 let digest = Sha256::digest(bytes);
636 let mut out = String::with_capacity(digest.len() * 2);
637 for byte in digest {
638 use std::fmt::Write as _;
639 let _ = write!(&mut out, "{byte:02x}");
640 }
641 out
642}
643
644fn empty_array() -> Value {
645 Value::Tensor(Tensor::zeros(vec![0, 0]))
646}
647
648fn requested_output_count() -> usize {
649 crate::output_count::current_output_count()
650 .or_else(crate::output_context::requested_output_count)
651 .unwrap_or(0)
652}
653
654fn pcode_error(error: &'static BuiltinErrorDescriptor) -> RuntimeError {
655 pcode_error_with_detail(error, "")
656}
657
658pub fn invalid_pcode_runtime_error(detail: impl AsRef<str>) -> RuntimeError {
659 pcode_error_with_detail(&ERROR_INVALID_PCODE, detail)
660}
661
662fn pcode_error_with_detail(
663 error: &'static BuiltinErrorDescriptor,
664 detail: impl AsRef<str>,
665) -> RuntimeError {
666 let detail = detail.as_ref();
667 let message = if detail.is_empty() {
668 error.message.to_string()
669 } else {
670 format!("{}: {detail}", error.message)
671 };
672 let mut builder = build_runtime_error(message).with_builtin(BUILTIN_NAME);
673 if let Some(identifier) = error.identifier {
674 builder = builder.with_identifier(identifier);
675 }
676 builder.build()
677}
678
679fn map_control_flow(err: RuntimeError) -> RuntimeError {
680 let identifier = err.identifier().map(str::to_string);
681 let mut builder = build_runtime_error(format!("{BUILTIN_NAME}: {}", err.message()))
682 .with_builtin(BUILTIN_NAME)
683 .with_source(err);
684 if let Some(identifier) = identifier {
685 builder = builder.with_identifier(identifier);
686 }
687 builder.build()
688}
689
690#[cfg(test)]
691mod tests {
692 use super::*;
693 use crate::builtins::io::repl_fs::REPL_FS_TEST_LOCK;
694 use futures::executor::block_on;
695 use std::env;
696
697 struct CwdGuard {
698 original: PathBuf,
699 }
700
701 impl Drop for CwdGuard {
702 fn drop(&mut self) {
703 let _ = env::set_current_dir(&self.original);
704 }
705 }
706
707 fn push_cwd(path: &Path) -> CwdGuard {
708 let original = env::current_dir().expect("current dir");
709 env::set_current_dir(path).expect("set current dir");
710 CwdGuard { original }
711 }
712
713 #[test]
714 fn pcode_creates_obscured_executable_p_file_from_source_name() {
715 let _provider_lock = runmat_filesystem::provider_override_lock();
716 let _lock = REPL_FS_TEST_LOCK
717 .lock()
718 .unwrap_or_else(|err| err.into_inner());
719 let temp = tempfile::TempDir::new().expect("tempdir");
720 std::fs::write(temp.path().join("worker.m"), "answer = 42;\n").expect("write source");
721 let _cwd = push_cwd(temp.path());
722
723 let result = block_on(evaluate(&[Value::from("worker")])).expect("pcode");
724 assert_eq!(result.generated.len(), 1);
725 assert!(result.generated[0].ends_with("worker.p"));
726 let encoded = std::fs::read_to_string(temp.path().join("worker.p")).expect("read pcode");
727 assert!(encoded.starts_with(RUNMAT_PCODE_MAGIC));
728 assert!(!encoded.contains("answer = 42"));
729 let decoded = decode_pcode_bytes(encoded.as_bytes()).expect("decode pcode");
730 assert_eq!(decoded, "answer = 42;\n");
731 }
732
733 #[test]
734 fn pcode_inplace_writes_next_to_source() {
735 let _provider_lock = runmat_filesystem::provider_override_lock();
736 let _lock = REPL_FS_TEST_LOCK
737 .lock()
738 .unwrap_or_else(|err| err.into_inner());
739 let temp = tempfile::TempDir::new().expect("tempdir");
740 let src_dir = temp.path().join("src");
741 std::fs::create_dir_all(&src_dir).expect("create src");
742 let source = src_dir.join("worker.m");
743 std::fs::write(&source, "value = 7;\n").expect("write source");
744 let _cwd = push_cwd(temp.path());
745
746 let result = block_on(evaluate(&[
747 Value::from(source.to_string_lossy().to_string()),
748 Value::from("-inplace"),
749 ]))
750 .expect("pcode");
751 assert_eq!(result.generated.len(), 1);
752 assert!(result.generated[0].ends_with("src/worker.p"));
753 assert!(src_dir.join("worker.p").is_file());
754 assert!(!temp.path().join("worker.p").exists());
755 }
756
757 #[test]
758 fn pcode_folder_preserves_package_and_class_suffix_in_current_dir() {
759 let _provider_lock = runmat_filesystem::provider_override_lock();
760 let _lock = REPL_FS_TEST_LOCK
761 .lock()
762 .unwrap_or_else(|err| err.into_inner());
763 let temp = tempfile::TempDir::new().expect("tempdir");
764 let source_root = temp.path().join("source").join("+pkg").join("@Widget");
765 std::fs::create_dir_all(&source_root).expect("create source root");
766 std::fs::write(source_root.join("Widget.m"), "classdef Widget\nend\n")
767 .expect("write class source");
768 let out = temp.path().join("out");
769 std::fs::create_dir_all(&out).expect("create out");
770 let _cwd = push_cwd(&out);
771
772 let result = block_on(evaluate(&[Value::from(
773 source_root.to_string_lossy().to_string(),
774 )]))
775 .expect("pcode");
776 assert_eq!(result.generated.len(), 1);
777 assert!(result.generated[0].ends_with("+pkg/@Widget/Widget.p"));
778 assert!(out.join("+pkg/@Widget/Widget.p").is_file());
779 }
780
781 #[test]
782 fn pcode_wildcard_ignores_non_m_files() {
783 let _provider_lock = runmat_filesystem::provider_override_lock();
784 let _lock = REPL_FS_TEST_LOCK
785 .lock()
786 .unwrap_or_else(|err| err.into_inner());
787 let temp = tempfile::TempDir::new().expect("tempdir");
788 std::fs::write(temp.path().join("one.m"), "one = 1;\n").expect("write one");
789 std::fs::write(temp.path().join("one.txt"), "not matlab\n").expect("write txt");
790 let _cwd = push_cwd(temp.path());
791
792 let result = block_on(evaluate(&[Value::from("one.*")])).expect("pcode");
793 assert_eq!(result.generated.len(), 1);
794 assert!(result.generated[0].ends_with("one.p"));
795 assert!(temp.path().join("one.p").is_file());
796 assert!(!temp.path().join("one.txt.p").exists());
797 }
798
799 #[test]
800 fn pcode_rejects_live_scripts_and_unknown_options() {
801 let err = block_on(evaluate(&[Value::from("demo.mlx")]))
802 .expect_err("live script source is unsupported");
803 assert_eq!(err.identifier(), Some("RunMat:pcode:UnsupportedExtension"));
804
805 let err = block_on(evaluate(&[Value::from("demo"), Value::from("-bad")]))
806 .expect_err("unknown option is invalid");
807 assert_eq!(err.identifier(), Some("RunMat:pcode:InvalidOption"));
808 }
809
810 #[test]
811 fn invalid_pcode_decode_reports_error() {
812 let err = decode_pcode_bytes(b"not matlab pcode").expect_err("invalid pcode");
813 assert!(err.contains("not a RunMat-generated"));
814 }
815}