Skip to main content

runmat_runtime/builtins/io/repl_fs/
pcode.rs

1//! MATLAB-compatible `pcode` support for RunMat-generated P-code artefacts.
2//!
3//! MATLAB's proprietary P-code format is intentionally undocumented. RunMat
4//! therefore writes a content-obscured RunMat P-code container that can be
5//! executed by RunMat while rejecting non-RunMat `.p` files deterministically.
6
7use 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}