fix(commands): tar/unzip member selectors and relay extraction, grep file filters, direct path execution
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"id": "exec_path_runs_a_script_on_a_mount",
|
||||
"seq": 962330,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "printf 'echo ran from script\\n' > /data/run.sh && /data/run.sh",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "ran from script\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "exec_path_relative_resolves_against_cwd",
|
||||
"seq": 962331,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "bash -c 'cd /data && ./run.sh'",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "ran from script\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "exec_path_shebang_options_apply",
|
||||
"seq": 962332,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "printf '#!/bin/sh -x\\necho traced\\n' > /data/t.sh && /data/t.sh",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "traced\n",
|
||||
"stderr": "+ echo traced\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "exec_path_missing_file_is_127",
|
||||
"seq": 962333,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "/data/nope.sh; echo exit=$?",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "exit=127\n",
|
||||
"stderr": "/data/nope.sh: No such file or directory\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "exec_path_directory_is_126",
|
||||
"seq": 962334,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "mkdir -p /data/adir && /data/adir; echo exit=$?",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "exit=126\n",
|
||||
"stderr": "/data/adir: Is a directory\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "exec_path_unknown_interpreter_is_127",
|
||||
"seq": 962335,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "printf '#!/usr/bin/env ruby\\nputs 1\\n' > /data/r.rb && /data/r.rb; echo exit=$?",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "exit=127\n",
|
||||
"stderr": "ruby: command not found\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"id": "xm_tar_extract_dash_C_into_the_other_mount",
|
||||
"seq": 962310,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"databricks",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"databricks-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"sharepoint-prefix",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "mkdir -p /data/xtar && echo xm | tee /data/xtar/f.txt > /dev/null && tar -czf /data/xtar.tgz -C /data xtar && tar -xzf /data/xtar.tgz -C /data2/xout && cat /data2/xout/xtar/f.txt",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "xm\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "xm_tar_selector_does_not_join_routing",
|
||||
"seq": 962311,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"databricks",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"databricks-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"sharepoint-prefix",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "tar -xOzf /data/xtar.tgz xtar/f.txt",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "xm\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "xm_tar_create_span_keeps_the_refusal",
|
||||
"seq": 962312,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"databricks",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"databricks-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"sharepoint-prefix",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "mkdir -p /data2/xsrc && echo s | tee /data2/xsrc/s.txt > /dev/null && tar -czf /data/xspan.tgz /data2/xsrc; echo exit=$?",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "exit=1\n",
|
||||
"stderr": "tar: paths span multiple mounts (/data/, /data2/), cross-mount not supported\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"id": "xm_unzip_dash_d_into_the_other_mount",
|
||||
"seq": 962315,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"databricks",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"databricks-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"sharepoint-prefix",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "mkdir -p /data/xzip && echo zx | tee /data/xzip/z.txt > /dev/null && zip -q -r /data/xz.zip /data/xzip && unzip -q -d /data2/xzout /data/xz.zip && cat /data2/xzout/data/xzip/z.txt",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "zx\n",
|
||||
"stderr": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+206
-2
@@ -529,13 +529,217 @@
|
||||
"command": "mkdir -p /data/ml && echo m > /data/ml/m.txt && tar -cf /data/ml.tar -C /data ml && tar -tf /data/ml.tar data; echo exit=$?",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "ml/\nml/m.txt\nexit=0\n",
|
||||
"stderr": ""
|
||||
"stdout": "exit=2\n",
|
||||
"stderr": "tar: data: Not found in archive\ntar: Exiting with failure status due to previous errors\n"
|
||||
},
|
||||
"flags": [
|
||||
"c",
|
||||
"C"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "arch_tar_member_selector_lists_the_subtree",
|
||||
"seq": 962301,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"databricks",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"databricks-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"sharepoint-prefix",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "mkdir -p /data/msel/sub && echo one | tee /data/msel/a.txt > /dev/null && echo two | tee /data/msel/sub/b.txt > /dev/null && tar -czf /data/msel.tgz -C /data msel && tar -tzf /data/msel.tgz msel/sub",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "msel/sub/\nmsel/sub/b.txt\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "arch_tar_member_selector_miss_exits_2",
|
||||
"seq": 962302,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"databricks",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"databricks-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"sharepoint-prefix",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "tar -tzf /data/msel.tgz nope; echo exit=$?",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "exit=2\n",
|
||||
"stderr": "tar: nope: Not found in archive\ntar: Exiting with failure status due to previous errors\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "arch_tar_extract_to_stdout",
|
||||
"seq": 962303,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"databricks",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"databricks-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"sharepoint-prefix",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "tar -xOzf /data/msel.tgz msel/a.txt",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "one\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "arch_tar_xvO_lists_names_on_stderr",
|
||||
"seq": 962304,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"databricks",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"databricks-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"sharepoint-prefix",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "tar -xvOzf /data/msel.tgz msel/a.txt",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "one\n",
|
||||
"stderr": "msel/a.txt\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "arch_tar_extract_selector_takes_only_its_subtree",
|
||||
"seq": 962305,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"databricks",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"databricks-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"sharepoint-prefix",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "tar -xzf /data/msel.tgz -C /data/mselout msel/sub && ls /data/mselout/msel && cat /data/mselout/msel/sub/b.txt",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "sub\ntwo\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "arch_tar_extract_defaults_to_the_cwd",
|
||||
"seq": 962306,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"databricks",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"databricks-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"sharepoint-prefix",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "mkdir -p /data/cwdx && bash -c 'cd /data/cwdx && tar -xzf /data/msel.tgz && ls msel'",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "a.txt\nsub\n",
|
||||
"stderr": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"id": "grep_include_filters_the_walk",
|
||||
"seq": 962320,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"databricks",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"databricks-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"sharepoint-prefix",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "mkdir -p /data/gflt/sub && echo 'score 9' | tee /data/gflt/notes.tex > /dev/null && echo 'score 8' | tee /data/gflt/notes.txt > /dev/null && echo 'score 7' | tee /data/gflt/sub/inner.tex > /dev/null && grep -RInE --include='*.tex' score /data/gflt",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "/data/gflt/notes.tex:1:score 9\n/data/gflt/sub/inner.tex:1:score 7\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "grep_exclude_wins_over_include",
|
||||
"seq": 962321,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"databricks",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"databricks-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"sharepoint-prefix",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "grep -r --include='*.tex' --exclude='notes.*' score /data/gflt",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "/data/gflt/sub/inner.tex:score 7\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "grep_exclude_dir_prunes_the_walk",
|
||||
"seq": 962322,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"databricks",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"databricks-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"sharepoint-prefix",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "grep -r --include='*.tex' --exclude-dir=sub score /data/gflt",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "/data/gflt/notes.tex:score 9\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "grep_include_with_a_slash_matches_nothing",
|
||||
"seq": 962323,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"databricks",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"databricks-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"sharepoint-prefix",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "grep -r --include='sub/*.tex' score /data/gflt; echo exit=$?",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "exit=1\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "grep_include_filters_an_explicit_operand_in_silence",
|
||||
"seq": 962324,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"databricks",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"databricks-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"sharepoint-prefix",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "grep --include='*.tex' -n score /data/gflt/notes.txt; echo exit=$?",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "exit=1\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "grep_dash_a_reads_binary_extensions_in_the_walk",
|
||||
"seq": 962325,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"databricks",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"databricks-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"sharepoint-prefix",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "echo 'score binary' | tee /data/gflt/d.parquet > /dev/null && grep -r score /data/gflt | grep -c parquet; grep -ra score /data/gflt | grep -c parquet",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "0\n1\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "grep_dash_a_is_accepted_on_an_explicit_operand",
|
||||
"seq": 962326,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"databricks",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"databricks-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"sharepoint-prefix",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "grep -aoiE 'SCORE' /data/gflt/notes.tex",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "score\n",
|
||||
"stderr": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from mirage.commands.builtin.generic.archive.walk import StatFn
|
||||
from mirage.types import FileType, PathSpec
|
||||
from mirage.utils.errors import FS_ERRORS
|
||||
|
||||
|
||||
def extract_dest(explicit: PathSpec | str | None, cwd: PathSpec | str,
|
||||
relay: bool) -> str:
|
||||
"""Where extraction lands: the explicit operand, else the cwd.
|
||||
|
||||
Relay doors route by full virtual path, accessor doors by
|
||||
mount-relative path, so the same operand renders differently per
|
||||
door space. Outside a workspace the cwd arrives as a plain string
|
||||
and the two spaces coincide.
|
||||
|
||||
Args:
|
||||
explicit (PathSpec | str | None): tar's last -C or unzip's -d,
|
||||
when the line named one.
|
||||
cwd (PathSpec | str): the session working directory.
|
||||
relay (bool): True when the doors are dispatch-relayed.
|
||||
"""
|
||||
target = explicit if explicit is not None else cwd
|
||||
if isinstance(target, PathSpec):
|
||||
return target.virtual if relay else target.mount_path
|
||||
return target or "/"
|
||||
|
||||
|
||||
async def dir_exists(stat: StatFn, level: str) -> bool:
|
||||
"""Whether a directory already stands at this path.
|
||||
|
||||
Args:
|
||||
stat (StatFn): stat door in the same path space as ``level``.
|
||||
level (str): the directory path to probe.
|
||||
"""
|
||||
try:
|
||||
found = await stat(PathSpec.from_str_path(level))
|
||||
except FS_ERRORS:
|
||||
return False
|
||||
return found is not None and found.type == FileType.DIRECTORY
|
||||
|
||||
|
||||
async def ensure_dir(dir_path: str, mkdir_fn: Callable[..., Awaitable[None]],
|
||||
stat: StatFn, made: set[str]) -> None:
|
||||
"""Create one directory chain top-down, skipping what exists.
|
||||
|
||||
The dispatch mkdir op is single-level on most backends (the
|
||||
``mkdir_parents`` knob is a per-backend exception), so extraction
|
||||
walks the chain itself the way relay cp does: probe, then create
|
||||
only what is missing, memoized per run so an archive of many files
|
||||
stats each ancestor once.
|
||||
|
||||
Args:
|
||||
dir_path (str): the directory whose chain must exist.
|
||||
mkdir_fn (Callable): mkdir door, single level.
|
||||
stat (StatFn): stat door in the same path space.
|
||||
made (set[str]): levels already ensured this run, updated here.
|
||||
"""
|
||||
parts = [p for p in dir_path.strip("/").split("/") if p]
|
||||
for i in range(1, len(parts) + 1):
|
||||
level = "/" + "/".join(parts[:i])
|
||||
if level in made:
|
||||
continue
|
||||
if not await dir_exists(stat, level):
|
||||
await mkdir_fn(PathSpec.from_str_path(level))
|
||||
made.add(level)
|
||||
@@ -22,6 +22,8 @@ FANOUT_COMMANDS = frozenset({
|
||||
Cmd.STAT, Cmd.STRINGS, Cmd.TAC, Cmd.LS, Cmd.FIND, Cmd.RM, Cmd.RMDIR,
|
||||
Cmd.UNLINK, Cmd.TOUCH, Cmd.MKDIR, Cmd.TEE
|
||||
})
|
||||
RELAY_COMMANDS = frozenset(
|
||||
{Cmd.CP, Cmd.MV, Cmd.DIFF, Cmd.CMP, Cmd.PASTE, Cmd.COMM, Cmd.JOIN})
|
||||
RELAY_COMMANDS = frozenset({
|
||||
Cmd.CP, Cmd.MV, Cmd.DIFF, Cmd.CMP, Cmd.PASTE, Cmd.COMM, Cmd.JOIN, Cmd.TAR,
|
||||
Cmd.UNZIP
|
||||
})
|
||||
CROSS_MOUNT_COMMANDS = STREAM_COMMANDS | FANOUT_COMMANDS | RELAY_COMMANDS
|
||||
|
||||
@@ -21,6 +21,8 @@ from mirage.commands.builtin.generic.crossmount.relay.diff import run_diff
|
||||
from mirage.commands.builtin.generic.crossmount.relay.join import run_join
|
||||
from mirage.commands.builtin.generic.crossmount.relay.mv import run_mv
|
||||
from mirage.commands.builtin.generic.crossmount.relay.paste import run_paste
|
||||
from mirage.commands.builtin.generic.crossmount.relay.tar import run_tar
|
||||
from mirage.commands.builtin.generic.crossmount.relay.unzip import run_unzip
|
||||
from mirage.commands.builtin.generic.crossmount.types import Cmd, CrossResult
|
||||
from mirage.commands.spec.types import FlagValue
|
||||
from mirage.runtime.types import DispatchFn
|
||||
@@ -30,6 +32,7 @@ from mirage.types import PathSpec
|
||||
async def run_relay(
|
||||
cmd_name: str,
|
||||
scopes: list[PathSpec],
|
||||
text_args: list[str],
|
||||
flag_kwargs: dict[str, FlagValue],
|
||||
dispatch: DispatchFn,
|
||||
storage_key: Callable[[PathSpec], str] | None = None) -> CrossResult:
|
||||
@@ -40,8 +43,10 @@ async def run_relay(
|
||||
its primitive mode, so output matches the single-mount commands.
|
||||
|
||||
Args:
|
||||
cmd_name (str): One of cp, mv, diff, cmp, paste, comm, join.
|
||||
cmd_name (str): One of cp, mv, diff, cmp, paste, comm, join, tar.
|
||||
scopes (list[PathSpec]): Path operands in command-line order.
|
||||
text_args (list[str]): Positional text operands (tar's member
|
||||
selectors; empty for the transfer and merge commands).
|
||||
flag_kwargs (dict): Flags parsed against the shared command spec.
|
||||
dispatch (DispatchFn): Workspace operation dispatcher.
|
||||
storage_key (Callable | None): Maps an operand to its storage
|
||||
@@ -60,4 +65,8 @@ async def run_relay(
|
||||
return await run_comm(scopes, flag_kwargs, dispatch)
|
||||
if cmd_name == Cmd.JOIN:
|
||||
return await run_join(scopes, flag_kwargs, dispatch)
|
||||
if cmd_name == Cmd.TAR:
|
||||
return await run_tar(scopes, text_args, flag_kwargs, dispatch)
|
||||
if cmd_name == Cmd.UNZIP:
|
||||
return await run_unzip(scopes, text_args, flag_kwargs, dispatch)
|
||||
return await run_cmp(scopes, flag_kwargs, dispatch)
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.commands.builtin.generic.crossmount.types import CrossResult
|
||||
from mirage.commands.builtin.generic.crossmount.utils import \
|
||||
transfer_primitives
|
||||
from mirage.commands.builtin.generic.tar.tar import tar
|
||||
from mirage.commands.spec import SPECS
|
||||
from mirage.commands.spec.types import FlagValue, FlagView
|
||||
from mirage.runtime.types import DispatchFn
|
||||
from mirage.types import PathSpec
|
||||
|
||||
|
||||
async def _no_walk(path: PathSpec, find_type: str) -> list[str]:
|
||||
raise ValueError("tar: create never runs on the relay tier")
|
||||
|
||||
|
||||
async def _no_dir_probe(path: PathSpec) -> bool:
|
||||
raise ValueError("tar: create never runs on the relay tier")
|
||||
|
||||
|
||||
async def run_tar(scopes: list[PathSpec], text_args: list[str],
|
||||
flag_kwargs: dict[str, FlagValue],
|
||||
dispatch: DispatchFn) -> CrossResult:
|
||||
"""Run a -t/-x tar whose archive and -C destination span mounts.
|
||||
|
||||
Pure wiring: the shared generic runs on dispatch-relayed doors, so
|
||||
the archive is read from its mount and every extracted path lands
|
||||
on whichever mount owns it. Create mode never reaches here: the
|
||||
executor keeps a create-mode span on the plain refusal, because its
|
||||
planner walks one backend's tree and relay doors would cross nested
|
||||
mount boundaries the planner is required to refuse.
|
||||
|
||||
Args:
|
||||
scopes (list[PathSpec]): Path operands in command-line order.
|
||||
text_args (list[str]): The -t/-x member selectors, as typed.
|
||||
flag_kwargs (dict): Flags parsed against the shared tar spec,
|
||||
with path-valued flags as resolved virtual strings.
|
||||
dispatch (DispatchFn): Workspace operation dispatcher.
|
||||
"""
|
||||
fl = FlagView(flag_kwargs, spec=SPECS["tar"])
|
||||
prim = transfer_primitives(dispatch)
|
||||
archive = fl.as_str("f")
|
||||
directories = [str(part) for part in fl.as_list("C")]
|
||||
return await tar(
|
||||
[],
|
||||
read_bytes=prim["read_bytes"],
|
||||
write_bytes=prim["write"],
|
||||
mkdir_fn=prim["mkdir"],
|
||||
stat=prim["stat"],
|
||||
walk=_no_walk,
|
||||
is_dir=_no_dir_probe,
|
||||
selectors=list(text_args),
|
||||
x=fl.as_bool("x"),
|
||||
t=fl.as_bool("t"),
|
||||
z=fl.as_bool("z"),
|
||||
j=fl.as_bool("j"),
|
||||
J=fl.as_bool("J"),
|
||||
v=fl.as_bool("v"),
|
||||
to_stdout=fl.as_bool("to_stdout"),
|
||||
f=PathSpec.from_str_path(archive) if archive else None,
|
||||
C=[PathSpec.from_str_path(d) for d in directories] or None,
|
||||
strip_components=fl.as_str("strip_components"),
|
||||
relay=True,
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.commands.builtin.generic.crossmount.types import CrossResult
|
||||
from mirage.commands.builtin.generic.crossmount.utils import \
|
||||
transfer_primitives
|
||||
from mirage.commands.builtin.generic.unzip import unzip
|
||||
from mirage.commands.spec import SPECS
|
||||
from mirage.commands.spec.types import FlagValue, FlagView
|
||||
from mirage.runtime.types import DispatchFn
|
||||
from mirage.types import PathSpec
|
||||
|
||||
|
||||
async def run_unzip(scopes: list[PathSpec], text_args: list[str],
|
||||
flag_kwargs: dict[str, FlagValue],
|
||||
dispatch: DispatchFn) -> CrossResult:
|
||||
"""Run an unzip whose archive and -d destination span mounts.
|
||||
|
||||
Pure wiring: the shared generic runs on dispatch-relayed doors, so
|
||||
the archive is read from its mount and every extracted path lands
|
||||
on whichever mount owns it.
|
||||
|
||||
Args:
|
||||
scopes (list[PathSpec]): Path operands; the archive is first.
|
||||
text_args (list[str]): Info-ZIP member filespecs, as typed.
|
||||
flag_kwargs (dict): Flags parsed against the shared unzip spec,
|
||||
with path-valued flags as resolved virtual strings.
|
||||
dispatch (DispatchFn): Workspace operation dispatcher.
|
||||
"""
|
||||
fl = FlagView(flag_kwargs, spec=SPECS["unzip"])
|
||||
prim = transfer_primitives(dispatch)
|
||||
# Scopes arrive in line order and include the -d flag's value, so
|
||||
# the archive is the first scope that is not the destination.
|
||||
dest = fl.as_str("d")
|
||||
operands = [s for s in scopes if s.virtual != dest]
|
||||
return await unzip(
|
||||
operands or scopes,
|
||||
read_bytes=prim["read_bytes"],
|
||||
write_bytes=prim["write"],
|
||||
mkdir_fn=prim["mkdir"],
|
||||
stat=prim["stat"],
|
||||
members=tuple(text_args),
|
||||
o=fl.as_bool("o"),
|
||||
args_l=fl.as_bool("args_l"),
|
||||
d=fl.as_str("d"),
|
||||
q=fl.as_bool("q"),
|
||||
p=fl.as_bool("p"),
|
||||
t=fl.as_bool("t"),
|
||||
relay=True,
|
||||
)
|
||||
@@ -66,8 +66,8 @@ async def handle_cross_mount(
|
||||
try:
|
||||
strategy = strategy_for(cmd_name, flag_kwargs)
|
||||
if strategy is Strategy.RELAY:
|
||||
return await run_relay(cmd_name, scopes, flag_kwargs, dispatch,
|
||||
storage_key)
|
||||
return await run_relay(cmd_name, scopes, text_args, flag_kwargs,
|
||||
dispatch, storage_key)
|
||||
if strategy is Strategy.STREAM:
|
||||
return await run_stream(cmd_name, scopes, text_args, flag_kwargs,
|
||||
run_single)
|
||||
|
||||
@@ -85,6 +85,8 @@ class Cmd(StrEnum):
|
||||
PASTE = "paste"
|
||||
COMM = "comm"
|
||||
JOIN = "join"
|
||||
TAR = "tar"
|
||||
UNZIP = "unzip"
|
||||
|
||||
|
||||
CrossResult = tuple[ByteSource | None, IOResult]
|
||||
|
||||
@@ -5,10 +5,15 @@ from functools import partial
|
||||
|
||||
from mirage.cache.read_through import (cache_aware_bound_bytes,
|
||||
cache_aware_bound_stream)
|
||||
from mirage.commands.builtin.grep_helper import ( # yapf: disable
|
||||
compile_pattern, count_exit_stream, count_records_have_matches,
|
||||
exit_code_for, grep_files_only, grep_lines, grep_recursive, grep_stream,
|
||||
prefix_lines, resolve_pattern)
|
||||
from mirage.commands.builtin.grep_helper import WalkFilters # yapf: disable
|
||||
from mirage.commands.builtin.grep_helper import \
|
||||
compile_pattern # yapf: disable
|
||||
from mirage.commands.builtin.grep_helper import (count_exit_stream,
|
||||
count_records_have_matches,
|
||||
exit_code_for, file_admitted,
|
||||
grep_files_only, grep_lines,
|
||||
grep_recursive, grep_stream,
|
||||
prefix_lines, resolve_pattern)
|
||||
from mirage.commands.builtin.utils.lines import split_lines
|
||||
from mirage.commands.builtin.utils.output import (format_optional_records,
|
||||
format_records)
|
||||
@@ -44,6 +49,7 @@ class GrepFlags:
|
||||
max_count: int | None
|
||||
after_context: int
|
||||
before_context: int
|
||||
filters: WalkFilters
|
||||
|
||||
|
||||
def parse_flags(fl: FlagView, never_match: bool) -> GrepFlags:
|
||||
@@ -76,6 +82,10 @@ def parse_flags(fl: FlagView, never_match: bool) -> GrepFlags:
|
||||
max_count=fl.as_int("m"),
|
||||
after_context=a_ctx if a_ctx is not None else (c_ctx or 0),
|
||||
before_context=b_ctx if b_ctx is not None else (c_ctx or 0),
|
||||
filters=WalkFilters(include=tuple(fl.as_list("include")),
|
||||
exclude=tuple(fl.as_list("exclude")),
|
||||
exclude_dir=tuple(fl.as_list("exclude_dir")),
|
||||
text=fl.as_bool("text")),
|
||||
)
|
||||
|
||||
|
||||
@@ -149,6 +159,7 @@ async def grep(
|
||||
basic=f.basic_regexp,
|
||||
warnings=warnings,
|
||||
read_stream_fn=None,
|
||||
filters=f.filters,
|
||||
)
|
||||
results.extend(respell_raw(hits, p.virtual, p.raw_path))
|
||||
stderr = format_optional_records(warnings)
|
||||
@@ -197,8 +208,11 @@ async def grep(
|
||||
max_count=f.max_count,
|
||||
warnings=warnings,
|
||||
read_stream_fn=None,
|
||||
filters=f.filters,
|
||||
)
|
||||
all_results.extend(respell_raw(res, p.virtual, p.raw_path))
|
||||
elif not file_admitted(p.virtual, f.filters):
|
||||
continue
|
||||
else:
|
||||
data = split_lines(
|
||||
(await rb(p.virtual)).decode(errors="replace"))
|
||||
@@ -237,6 +251,8 @@ async def grep(
|
||||
multi_warnings.append(
|
||||
f"grep: {p.raw_path}: Is a directory")
|
||||
continue
|
||||
if not file_admitted(p.virtual, f.filters):
|
||||
continue
|
||||
data = split_lines((await
|
||||
rb(p.virtual)).decode(errors="replace"))
|
||||
# -l returned at the top of this function, so every path
|
||||
@@ -272,6 +288,11 @@ async def grep(
|
||||
stderr = f"grep: {paths[0].raw_path}: Is a directory\n".encode()
|
||||
return b"", IOResult(exit_code=2, stderr=stderr)
|
||||
|
||||
if not file_admitted(paths[0].virtual, f.filters):
|
||||
# GNU passes over a command-line file --include leaves out
|
||||
# in silence: no output, no diagnostic, exit "no match".
|
||||
return b"", IOResult(exit_code=1)
|
||||
|
||||
if read_stream is not None:
|
||||
source: AsyncIterator[bytes] = read_stream(paths[0])
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
|
||||
def is_create_mode(argv: Sequence[str]) -> bool:
|
||||
"""Whether a tar line reads the filesystem rather than an archive.
|
||||
|
||||
Only ``-c`` makes tar's operands source paths. Under ``-t`` and
|
||||
``-x`` they are member selectors matched against names inside the
|
||||
archive, so a selector that happens to spell a mount root is not a
|
||||
mount at all and refusing it would deny an ordinary listing.
|
||||
|
||||
Scanned raw because both callers fire before flag parsing: the
|
||||
mount-root policy at admission, and the cross-mount router deciding
|
||||
whether a span is real. Only the first word may be GNU's dashless
|
||||
option cluster (``tar cf a.tar d``), so a later bare word is an
|
||||
operand and cannot turn the mode on.
|
||||
|
||||
Args:
|
||||
argv (Sequence[str]): raw argv after the command name.
|
||||
"""
|
||||
for i, tok in enumerate(argv):
|
||||
if not isinstance(tok, str):
|
||||
continue
|
||||
if tok == "--create":
|
||||
return True
|
||||
if tok.startswith("--"):
|
||||
continue
|
||||
if tok.startswith("-"):
|
||||
if "c" in tok[1:]:
|
||||
return True
|
||||
continue
|
||||
if i == 0 and "c" in tok:
|
||||
return True
|
||||
return False
|
||||
@@ -3,6 +3,8 @@ import tarfile
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
|
||||
from mirage.commands.builtin.generic.archive.extract import (ensure_dir,
|
||||
extract_dest)
|
||||
from mirage.commands.builtin.generic.archive.walk import (DirProbe, StatFn,
|
||||
WalkFn)
|
||||
from mirage.commands.builtin.generic.tar.constants import (READ_MODES,
|
||||
@@ -41,6 +43,79 @@ def _stderr(lines: list[str]) -> bytes:
|
||||
return ("\n".join(lines) + "\n").encode() if lines else b""
|
||||
|
||||
|
||||
MISS_TRAILER = "tar: Exiting with failure status due to previous errors"
|
||||
DOTDOT_NOTICE = "tar: Removing leading `../' from member names"
|
||||
|
||||
|
||||
def _matches(name: str, selector: str) -> bool:
|
||||
"""Whether one -t/-x member selector keeps an archive member.
|
||||
|
||||
GNU matches the stored spelling exactly (``memory/x`` does not find
|
||||
``./memory/x``), and a selector naming a directory takes its whole
|
||||
subtree, with or without the trailing slash.
|
||||
|
||||
Args:
|
||||
name (str): the member name as stored in the archive.
|
||||
selector (str): the operand as typed.
|
||||
"""
|
||||
base = selector.rstrip("/")
|
||||
trimmed = name.rstrip("/")
|
||||
return trimmed == base or trimmed.startswith(base + "/")
|
||||
|
||||
|
||||
def _selected(names: list[str],
|
||||
selectors: list[str]) -> tuple[set[int], list[str]]:
|
||||
"""Member indices the selectors keep, and the misses they report.
|
||||
|
||||
No selector keeps everything. A selector that matches nothing is
|
||||
GNU's per-operand diagnostic, reported in operand order; the caller
|
||||
appends the one failure trailer.
|
||||
|
||||
Args:
|
||||
names (list[str]): member names in archive order.
|
||||
selectors (list[str]): the -t/-x operands as typed.
|
||||
"""
|
||||
if not selectors:
|
||||
return set(range(len(names))), []
|
||||
keep: set[int] = set()
|
||||
misses: list[str] = []
|
||||
for sel in selectors:
|
||||
hit = False
|
||||
for idx, name in enumerate(names):
|
||||
if _matches(name, sel):
|
||||
keep.add(idx)
|
||||
hit = True
|
||||
if not hit:
|
||||
misses.append(f"tar: {sel}: Not found in archive")
|
||||
return keep, misses
|
||||
|
||||
|
||||
def _out_parts(name: str, strip_n: int, notices: list[str]) -> list[str]:
|
||||
"""The destination-relative components one member extracts to.
|
||||
|
||||
GNU strips ``--strip-components`` off the stored spelling first, in
|
||||
which a leading ``.`` counts as a component (``--strip-components=1``
|
||||
turns ``./a/b`` into ``a/b``). Only then is the remainder cleaned
|
||||
for the filesystem: ``.`` components vanish (a real OS resolves
|
||||
them; a virtual path must not keep a literal ``.`` directory) and a
|
||||
leading ``..`` is removed with GNU's one notice per run.
|
||||
|
||||
Args:
|
||||
name (str): the member name as stored in the archive.
|
||||
strip_n (int): components to strip off the stored name.
|
||||
notices (list[str]): run-level notice sink, appended in place.
|
||||
"""
|
||||
parts = name.rstrip("/").split("/")
|
||||
if strip_n > 0:
|
||||
parts = parts[strip_n:]
|
||||
parts = [p for p in parts if p not in ("", ".")]
|
||||
while parts and parts[0] == "..":
|
||||
if DOTDOT_NOTICE not in notices:
|
||||
notices.append(DOTDOT_NOTICE)
|
||||
parts.pop(0)
|
||||
return parts
|
||||
|
||||
|
||||
def _info(member: Member, size: int) -> tarfile.TarInfo:
|
||||
"""The header for one member, typed the way its kind demands.
|
||||
|
||||
@@ -88,6 +163,7 @@ async def _create_archive(
|
||||
async def _list_archive(
|
||||
archive_path: PathSpec,
|
||||
mode_suffix: CompressionSuffix,
|
||||
selectors: list[str],
|
||||
read_bytes: Callable[..., Awaitable[bytes]],
|
||||
) -> tuple[ByteSource | None, IOResult]:
|
||||
data = await read_bytes(archive_path)
|
||||
@@ -97,7 +173,13 @@ async def _list_archive(
|
||||
member.name + "/" if member.isdir() else member.name
|
||||
for member in tf.getmembers()
|
||||
]
|
||||
return ("\n".join(names) + "\n").encode(), IOResult()
|
||||
keep, misses = _selected(names, selectors)
|
||||
shown = [name for idx, name in enumerate(names) if idx in keep]
|
||||
stdout = ("\n".join(shown) + "\n").encode() if shown else None
|
||||
if misses:
|
||||
return stdout, IOResult(exit_code=2,
|
||||
stderr=_stderr(misses + [MISS_TRAILER]))
|
||||
return stdout, IOResult()
|
||||
|
||||
|
||||
async def _extract_archive(
|
||||
@@ -106,47 +188,86 @@ async def _extract_archive(
|
||||
mode_suffix: CompressionSuffix,
|
||||
strip_n: int,
|
||||
verbose: bool,
|
||||
to_stdout: bool,
|
||||
selectors: list[str],
|
||||
relay: bool,
|
||||
read_bytes: Callable[..., Awaitable[bytes]],
|
||||
write_bytes: Callable[..., Awaitable[None]],
|
||||
mkdir_fn: Callable[..., Awaitable[None]],
|
||||
stat: StatFn,
|
||||
) -> tuple[ByteSource | None, IOResult]:
|
||||
data = await read_bytes(archive_path)
|
||||
writes: dict[str, ByteSource] = {}
|
||||
names: list[str] = []
|
||||
notices: list[str] = []
|
||||
made: set[str] = set()
|
||||
extracted_bytes: list[bytes] = []
|
||||
with tarfile.open(fileobj=io.BytesIO(data),
|
||||
mode=_read_mode(mode_suffix)) as tf:
|
||||
for member in tf.getmembers():
|
||||
members = tf.getmembers()
|
||||
listed = [
|
||||
member.name + "/" if member.isdir() else member.name
|
||||
for member in members
|
||||
]
|
||||
keep, misses = _selected(listed, selectors)
|
||||
for idx, member in enumerate(members):
|
||||
if idx not in keep:
|
||||
continue
|
||||
# A symlink member has no bytes to write and no namespace to
|
||||
# write into from here (links are workspace state, not the
|
||||
# backend's), so extraction skips it rather than dropping an
|
||||
# empty file where a link belongs.
|
||||
if not member.isfile() and not member.isdir():
|
||||
continue
|
||||
name_parts = member.name.rstrip("/").split("/")
|
||||
if strip_n > 0:
|
||||
name_parts = name_parts[strip_n:]
|
||||
if not name_parts or name_parts == [""]:
|
||||
continue
|
||||
out_path = dest_path.rstrip("/") + "/" + "/".join(name_parts)
|
||||
if member.isdir():
|
||||
# A directory member is the only record an empty
|
||||
# directory leaves, so it has to be recreated even
|
||||
# though nothing is written inside it.
|
||||
await mkdir_fn(PathSpec.from_str_path(out_path), parents=True)
|
||||
names.append(member.name.rstrip("/") + "/")
|
||||
if not to_stdout:
|
||||
# A directory member is the only record an empty
|
||||
# directory leaves, so it has to be recreated even
|
||||
# though nothing is written inside it. Under -O
|
||||
# nothing reaches the filesystem at all.
|
||||
parts = _out_parts(member.name, strip_n, notices)
|
||||
if parts:
|
||||
out_dir = dest_path.rstrip("/") + "/" + "/".join(parts)
|
||||
await ensure_dir(out_dir, mkdir_fn, stat, made)
|
||||
names.append(member.name.rstrip("/") + "/")
|
||||
continue
|
||||
extracted = tf.extractfile(member)
|
||||
if not extracted:
|
||||
continue
|
||||
content = extracted.read()
|
||||
if to_stdout:
|
||||
extracted_bytes.append(content)
|
||||
names.append(member.name)
|
||||
continue
|
||||
parts = _out_parts(member.name, strip_n, notices)
|
||||
if not parts:
|
||||
continue
|
||||
out_path = dest_path.rstrip("/") + "/" + "/".join(parts)
|
||||
parent = out_path.rsplit("/", 1)[0] or "/"
|
||||
if parent != "/":
|
||||
await mkdir_fn(PathSpec.from_str_path(parent), parents=True)
|
||||
await write_bytes(PathSpec.from_str_path(out_path), content)
|
||||
writes[out_path] = content
|
||||
await ensure_dir(parent, mkdir_fn, stat, made)
|
||||
await write_bytes(PathSpec.from_str_path(out_path), data=content)
|
||||
if not relay:
|
||||
# Relay writes land on whichever mount owns each path
|
||||
# and invalidate through the dispatcher; keying them here
|
||||
# would have the runner prefix them onto this mount.
|
||||
writes[out_path] = content
|
||||
names.append(member.name)
|
||||
stdout = ("\n".join(names) + "\n").encode() if verbose and names else None
|
||||
return stdout, IOResult(writes=writes)
|
||||
if to_stdout:
|
||||
# GNU moves the verbose listing to stderr when stdout carries
|
||||
# the member bytes.
|
||||
stdout: ByteSource | None = b"".join(extracted_bytes) or None
|
||||
stderr_lines = notices + (names if verbose else [])
|
||||
else:
|
||||
listing = ("\n".join(names) + "\n").encode() if verbose and names \
|
||||
else None
|
||||
stdout = listing
|
||||
stderr_lines = list(notices)
|
||||
if misses:
|
||||
stderr_lines = stderr_lines + misses + [MISS_TRAILER]
|
||||
return stdout, IOResult(exit_code=2 if misses else 0,
|
||||
stderr=_stderr(stderr_lines),
|
||||
writes=writes)
|
||||
|
||||
|
||||
async def tar(
|
||||
@@ -158,6 +279,7 @@ async def tar(
|
||||
stat: StatFn,
|
||||
walk: WalkFn,
|
||||
is_dir: DirProbe,
|
||||
selectors: list[str] | None = None,
|
||||
c: bool = False,
|
||||
x: bool = False,
|
||||
t: bool = False,
|
||||
@@ -166,16 +288,25 @@ async def tar(
|
||||
J: bool = False,
|
||||
v: bool = False,
|
||||
h: bool = False,
|
||||
to_stdout: bool = False,
|
||||
f: PathSpec | None = None,
|
||||
C: list[PathSpec] | None = None,
|
||||
strip_components: str | None = None,
|
||||
exclude: str | None = None,
|
||||
links: LinkView | None = None,
|
||||
mounts: MountView | None = None,
|
||||
cwd: PathSpec | str = "/",
|
||||
relay: bool = False,
|
||||
) -> tuple[ByteSource | None, IOResult]:
|
||||
archive = f if f else None
|
||||
if relay and archive is not None:
|
||||
# Relay doors address by full virtual path (flat_scopes'
|
||||
# convention), not by the mount-relative key the wrapper's
|
||||
# accessor stamped.
|
||||
archive = PathSpec.from_str_path(archive.virtual)
|
||||
# Only the last -C is a destination; create checks every one.
|
||||
dest_path = C[-1].mount_path if C else "/"
|
||||
dest_path = extract_dest(C[-1] if C else None, cwd, relay)
|
||||
chosen = list(selectors or [])
|
||||
mode_suffix = _compression_suffix(z, j, J)
|
||||
strip_n = int(strip_components) if strip_components else 0
|
||||
if c:
|
||||
@@ -199,12 +330,13 @@ async def tar(
|
||||
if t:
|
||||
if archive is None:
|
||||
raise ValueError("tar: -f is required")
|
||||
return await _list_archive(archive, mode_suffix, read_bytes)
|
||||
return await _list_archive(archive, mode_suffix, chosen, read_bytes)
|
||||
if x:
|
||||
if archive is None:
|
||||
raise ValueError("tar: -f is required")
|
||||
return await _extract_archive(archive, dest_path, mode_suffix, strip_n,
|
||||
v, read_bytes, write_bytes, mkdir_fn)
|
||||
v, to_stdout, chosen, relay, read_bytes,
|
||||
write_bytes, mkdir_fn, stat)
|
||||
raise ValueError("tar: must specify -c, -x, or -t")
|
||||
|
||||
|
||||
@@ -221,6 +353,7 @@ class TarFlags:
|
||||
xz: bool = False
|
||||
verbose: bool = False
|
||||
deref: bool = False
|
||||
to_stdout: bool = False
|
||||
archive: PathSpec | None = None
|
||||
directories: tuple[PathSpec, ...] = ()
|
||||
strip_components: str | None = None
|
||||
@@ -239,6 +372,7 @@ def parse_flags(flags: Mapping[str, FlagValue]) -> TarFlags:
|
||||
xz=fl.as_bool("J"),
|
||||
verbose=fl.as_bool("v"),
|
||||
deref=fl.as_bool("h"),
|
||||
to_stdout=fl.as_bool("to_stdout"),
|
||||
archive=archive if isinstance(archive, PathSpec) else None,
|
||||
directories=tuple(fl.as_paths("C")),
|
||||
strip_components=fl.as_str("strip_components"),
|
||||
@@ -246,8 +380,16 @@ def parse_flags(flags: Mapping[str, FlagValue]) -> TarFlags:
|
||||
)
|
||||
|
||||
|
||||
async def tar_generic(paths, texts, opts: CommandOpts, read_bytes, write_bytes,
|
||||
mkdir_fn, stat, walk, is_dir):
|
||||
async def tar_generic(paths,
|
||||
texts,
|
||||
opts: CommandOpts,
|
||||
read_bytes,
|
||||
write_bytes,
|
||||
mkdir_fn,
|
||||
stat,
|
||||
walk,
|
||||
is_dir,
|
||||
relay: bool = False):
|
||||
parsed = parse_flags(opts.flags)
|
||||
return await tar(paths,
|
||||
read_bytes=read_bytes,
|
||||
@@ -256,6 +398,7 @@ async def tar_generic(paths, texts, opts: CommandOpts, read_bytes, write_bytes,
|
||||
stat=stat,
|
||||
walk=walk,
|
||||
is_dir=is_dir,
|
||||
selectors=list(texts),
|
||||
c=parsed.create,
|
||||
x=parsed.extract,
|
||||
t=parsed.list_only,
|
||||
@@ -264,9 +407,12 @@ async def tar_generic(paths, texts, opts: CommandOpts, read_bytes, write_bytes,
|
||||
J=parsed.xz,
|
||||
v=parsed.verbose,
|
||||
h=parsed.deref,
|
||||
to_stdout=parsed.to_stdout,
|
||||
f=parsed.archive,
|
||||
C=list(parsed.directories) or None,
|
||||
strip_components=parsed.strip_components,
|
||||
exclude=parsed.exclude,
|
||||
links=opts.ns.links if opts.ns is not None else None,
|
||||
mounts=opts.ns.mounts if opts.ns is not None else None)
|
||||
mounts=opts.ns.mounts if opts.ns is not None else None,
|
||||
cwd=opts.cwd,
|
||||
relay=relay)
|
||||
|
||||
@@ -4,6 +4,9 @@ import zipfile
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
|
||||
from mirage.commands.builtin.generic.archive.extract import (ensure_dir,
|
||||
extract_dest)
|
||||
from mirage.commands.builtin.generic.archive.walk import StatFn
|
||||
from mirage.commands.config import CommandOpts
|
||||
from mirage.commands.spec import SPECS
|
||||
from mirage.commands.spec.types import FlagValue, FlagView
|
||||
@@ -15,16 +18,6 @@ from mirage.utils.key_prefix import mount_prefix_of
|
||||
CAUTION_PREFIX = "caution: filename not matched: "
|
||||
|
||||
|
||||
def _resolve_dest(d: str | PathSpec | None, mount_prefix: str) -> str:
|
||||
d_str = d.virtual if isinstance(d, PathSpec) else d
|
||||
dest_raw = d_str if d_str else "/"
|
||||
if mount_prefix and dest_raw.startswith(mount_prefix + "/"):
|
||||
return dest_raw[len(mount_prefix):]
|
||||
if dest_raw == mount_prefix:
|
||||
return "/"
|
||||
return dest_raw
|
||||
|
||||
|
||||
def _spec_index(name: bytes, members: tuple[bytes, ...]) -> int | None:
|
||||
for i, member in enumerate(members):
|
||||
if fnmatch.fnmatchcase(name, member):
|
||||
@@ -60,12 +53,34 @@ def _cautions(unmatched: list[str]) -> str:
|
||||
return "".join(CAUTION_PREFIX + member + "\n" for member in unmatched)
|
||||
|
||||
|
||||
async def _make_dirs(dir_path: str, mkdir_fn: Callable[..., Awaitable[None]],
|
||||
stat: StatFn | None, made: set[str]) -> None:
|
||||
"""Create the chain for one entry, per door space.
|
||||
|
||||
With a stat door the shared single-level walk runs (dispatch mkdir
|
||||
is single-level on most backends); without one the accessor's own
|
||||
mkdir handles the chain, which is the pre-workspace construction
|
||||
path where no dispatcher exists.
|
||||
|
||||
Args:
|
||||
dir_path (str): the directory whose chain must exist.
|
||||
mkdir_fn (Callable): mkdir door.
|
||||
stat (StatFn | None): stat door in the same path space, if any.
|
||||
made (set[str]): levels already ensured this run.
|
||||
"""
|
||||
if stat is None:
|
||||
await mkdir_fn(PathSpec.from_str_path(dir_path), parents=True)
|
||||
return
|
||||
await ensure_dir(dir_path, mkdir_fn, stat, made)
|
||||
|
||||
|
||||
async def unzip(
|
||||
paths: list[PathSpec],
|
||||
*,
|
||||
read_bytes: Callable[..., Awaitable[bytes]],
|
||||
write_bytes: Callable[..., Awaitable[None]],
|
||||
mkdir_fn: Callable[..., Awaitable[None]],
|
||||
stat: StatFn | None = None,
|
||||
members: tuple[str, ...] = (),
|
||||
o: bool = False,
|
||||
args_l: bool = False,
|
||||
@@ -73,10 +88,17 @@ async def unzip(
|
||||
q: bool = False,
|
||||
p: bool = False,
|
||||
t: bool = False,
|
||||
cwd: PathSpec | str = "/",
|
||||
relay: bool = False,
|
||||
) -> tuple[ByteSource | None, IOResult]:
|
||||
if not paths:
|
||||
raise ValueError("unzip: missing operand")
|
||||
archive_path = paths[0]
|
||||
if relay:
|
||||
# Relay doors address by full virtual path (flat_scopes'
|
||||
# convention), not by the mount-relative key the wrapper's
|
||||
# accessor stamped.
|
||||
archive_path = PathSpec.from_str_path(archive_path.virtual)
|
||||
data = await read_bytes(archive_path)
|
||||
with zipfile.ZipFile(io.BytesIO(data), "r") as zf:
|
||||
selected, unmatched = _select(zf.infolist(), members)
|
||||
@@ -128,28 +150,34 @@ async def unzip(
|
||||
mount_prefix = mount_prefix_of(
|
||||
archive_path.virtual, archive_path.resource_path) if isinstance(
|
||||
archive_path, PathSpec) else ""
|
||||
dest = _resolve_dest(d, mount_prefix)
|
||||
dest = extract_dest(d, cwd, relay)
|
||||
writes: dict[str, ByteSource] = {}
|
||||
made: set[str] = set()
|
||||
output_lines: list[str] = []
|
||||
for info in selected:
|
||||
entry_name = info.filename.lstrip("/")
|
||||
out_path = dest.rstrip("/") + "/" + entry_name.rstrip("/")
|
||||
report_path = (mount_prefix +
|
||||
out_path) if mount_prefix else out_path
|
||||
report_path = out_path if relay else ((
|
||||
mount_prefix + out_path) if mount_prefix else out_path)
|
||||
if info.is_dir():
|
||||
# A directory entry is the only record an empty
|
||||
# directory leaves, so it has to be recreated even
|
||||
# though nothing is written inside it.
|
||||
await mkdir_fn(PathSpec.from_str_path(out_path), parents=True)
|
||||
await _make_dirs(out_path, mkdir_fn, stat, made)
|
||||
if not q:
|
||||
output_lines.append(f" creating: {report_path}/")
|
||||
continue
|
||||
content = zf.read(info)
|
||||
parent = out_path.rsplit("/", 1)[0] or "/"
|
||||
if parent != "/":
|
||||
await mkdir_fn(PathSpec.from_str_path(parent), parents=True)
|
||||
await write_bytes(PathSpec.from_str_path(out_path), content)
|
||||
writes[out_path] = content
|
||||
await _make_dirs(parent, mkdir_fn, stat, made)
|
||||
await write_bytes(PathSpec.from_str_path(out_path), data=content)
|
||||
if not relay:
|
||||
# Relay writes land on whichever mount owns each path
|
||||
# and invalidate through the dispatcher; keying them
|
||||
# here would have the runner prefix them onto this
|
||||
# mount.
|
||||
writes[out_path] = content
|
||||
if not q:
|
||||
output_lines.append(f" inflating: {report_path}")
|
||||
output = ("\n".join(output_lines) +
|
||||
@@ -187,17 +215,26 @@ def parse_flags(flags: Mapping[str, FlagValue]) -> UnzipFlags:
|
||||
)
|
||||
|
||||
|
||||
async def unzip_generic(paths, texts, opts: CommandOpts, read_bytes,
|
||||
write_bytes, mkdir_fn):
|
||||
async def unzip_generic(paths,
|
||||
texts,
|
||||
opts: CommandOpts,
|
||||
read_bytes,
|
||||
write_bytes,
|
||||
mkdir_fn,
|
||||
stat: StatFn | None = None,
|
||||
relay: bool = False):
|
||||
parsed = parse_flags(opts.flags)
|
||||
return await unzip(paths,
|
||||
read_bytes=read_bytes,
|
||||
write_bytes=write_bytes,
|
||||
mkdir_fn=mkdir_fn,
|
||||
stat=stat,
|
||||
members=tuple(texts),
|
||||
o=parsed.overwrite,
|
||||
args_l=parsed.list_only,
|
||||
d=parsed.dest,
|
||||
q=parsed.quiet,
|
||||
p=parsed.to_stdout,
|
||||
t=parsed.test_only)
|
||||
t=parsed.test_only,
|
||||
cwd=opts.cwd,
|
||||
relay=relay)
|
||||
|
||||
@@ -15,11 +15,15 @@
|
||||
from functools import partial
|
||||
|
||||
from mirage.accessor.base import Accessor
|
||||
from mirage.commands.builtin.generic.crossmount.utils import \
|
||||
transfer_primitives
|
||||
from mirage.commands.builtin.generic.tar import tar_generic
|
||||
from mirage.commands.builtin.generic_bind.adapter import (Builder, CommandIO,
|
||||
Operation, bound_op)
|
||||
from mirage.commands.builtin.generic_bind.archive_io import is_dir_of, walk_of
|
||||
from mirage.commands.config import CommandOpts
|
||||
from mirage.commands.spec import SPECS
|
||||
from mirage.commands.spec.types import FlagView
|
||||
from mirage.io.types import ByteSource, IOResult
|
||||
from mirage.types import PathSpec
|
||||
|
||||
@@ -30,6 +34,23 @@ async def tar(ops: CommandIO, accessor: Accessor, paths: list[PathSpec],
|
||||
if not ops.is_mounted(accessor):
|
||||
raise ValueError("tar: missing operand")
|
||||
resolved = await ops.resolve_glob(accessor, paths, opts.index)
|
||||
fl = FlagView(opts.flags, spec=SPECS["tar"])
|
||||
if opts.dispatch is not None and not fl.as_bool("c"):
|
||||
# -t reads and -x writes wherever cwd or -C says, which needs
|
||||
# not be this mount, so both run on dispatch-relayed doors and
|
||||
# each path routes to the mount that owns it. Only -c stays on
|
||||
# the accessor: its planner walks this mount's tree.
|
||||
prim = transfer_primitives(opts.dispatch)
|
||||
return await tar_generic(resolved,
|
||||
list(texts),
|
||||
opts,
|
||||
prim["read_bytes"],
|
||||
prim["write"],
|
||||
prim["mkdir"],
|
||||
prim["stat"],
|
||||
walk_of(ops, accessor, opts.index),
|
||||
is_dir_of(ops, accessor, opts.index),
|
||||
relay=True)
|
||||
return await tar_generic(resolved, list(texts), opts,
|
||||
bound_op(ops.read_bytes, accessor, opts.index),
|
||||
partial(ops.require(Operation.WRITE), accessor),
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
from functools import partial
|
||||
|
||||
from mirage.accessor.base import Accessor
|
||||
from mirage.commands.builtin.generic.crossmount.utils import \
|
||||
transfer_primitives
|
||||
from mirage.commands.builtin.generic.unzip import unzip_generic
|
||||
from mirage.commands.builtin.generic_bind.adapter import (Builder, CommandIO,
|
||||
Operation, bound_op)
|
||||
@@ -29,6 +31,19 @@ async def unzip(ops: CommandIO, accessor: Accessor, paths: list[PathSpec],
|
||||
if not ops.is_mounted(accessor) or not paths:
|
||||
raise ValueError("unzip: missing operand")
|
||||
resolved = await ops.resolve_glob(accessor, paths, opts.index)
|
||||
if opts.dispatch is not None:
|
||||
# Extraction writes wherever cwd or -d says, which need not be
|
||||
# this mount, so the doors are dispatch-relayed and each path
|
||||
# routes to the mount that owns it.
|
||||
prim = transfer_primitives(opts.dispatch)
|
||||
return await unzip_generic(resolved,
|
||||
list(texts),
|
||||
opts,
|
||||
prim["read_bytes"],
|
||||
prim["write"],
|
||||
prim["mkdir"],
|
||||
stat=prim["stat"],
|
||||
relay=True)
|
||||
return await unzip_generic(resolved, list(texts), opts,
|
||||
bound_op(ops.read_bytes, accessor, opts.index),
|
||||
partial(ops.require(Operation.WRITE), accessor),
|
||||
|
||||
@@ -12,9 +12,11 @@
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import fnmatch
|
||||
import re
|
||||
from collections.abc import (AsyncIterator, Awaitable, Callable, Mapping,
|
||||
Sequence)
|
||||
from dataclasses import dataclass
|
||||
|
||||
from mirage.commands.builtin.constants import PatternType
|
||||
from mirage.commands.builtin.grep_context import grep_context_lines
|
||||
@@ -44,6 +46,59 @@ BINARY_EXTENSIONS = frozenset({
|
||||
NEVER_MATCH = r"(?!)"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WalkFilters:
|
||||
"""GNU's file-selection flags, threaded as one value.
|
||||
|
||||
``include``/``exclude`` gate files, ``exclude_dir`` prunes
|
||||
directories from the -r walk, and ``text`` (-a) lets the walk read
|
||||
the extensions it would otherwise skip as binary. The empty value
|
||||
admits everything, which is what every caller without the flags
|
||||
passes.
|
||||
"""
|
||||
include: tuple[str, ...] = ()
|
||||
exclude: tuple[str, ...] = ()
|
||||
exclude_dir: tuple[str, ...] = ()
|
||||
text: bool = False
|
||||
|
||||
|
||||
NO_FILTERS = WalkFilters()
|
||||
|
||||
|
||||
def file_admitted(path: str, filters: WalkFilters) -> bool:
|
||||
"""GNU's --include/--exclude gate for one candidate file.
|
||||
|
||||
Globs match the base name with fnmatch wildcards, case sensitively
|
||||
(a glob carrying a slash therefore matches nothing, which is what
|
||||
GNU 3.11 answers too), and --exclude wins when both match. Applies
|
||||
to command-line files exactly as to walked ones, which is pinned
|
||||
GNU behavior: an explicit operand --include passes over is silently
|
||||
no match, not an error.
|
||||
|
||||
Args:
|
||||
path (str): candidate file path, any path space.
|
||||
filters (WalkFilters): the parsed selection flags.
|
||||
"""
|
||||
base = path.rstrip("/").rsplit("/", 1)[-1]
|
||||
if any(fnmatch.fnmatchcase(base, glob) for glob in filters.exclude):
|
||||
return False
|
||||
if filters.include:
|
||||
return any(fnmatch.fnmatchcase(base, glob) for glob in filters.include)
|
||||
return True
|
||||
|
||||
|
||||
def dir_admitted(path: str, filters: WalkFilters) -> bool:
|
||||
"""Whether the -r walk may descend into this directory.
|
||||
|
||||
Args:
|
||||
path (str): candidate directory path, any path space.
|
||||
filters (WalkFilters): the parsed selection flags.
|
||||
"""
|
||||
base = path.rstrip("/").rsplit("/", 1)[-1]
|
||||
return not any(
|
||||
fnmatch.fnmatchcase(base, glob) for glob in filters.exclude_dir)
|
||||
|
||||
|
||||
def classify_pattern(
|
||||
pattern: str,
|
||||
fixed_string: bool,
|
||||
@@ -164,9 +219,10 @@ def search_query(pattern: str, fixed_string: bool) -> str | None:
|
||||
|
||||
|
||||
_PUSHDOWN_SHAPING_BOOL = ("v", "n", "c", "args_l", "w", "o", "q", "H", "h",
|
||||
"args_I")
|
||||
"args_I", "text")
|
||||
_PUSHDOWN_SHAPING_INT = ("m", "A", "B", "C")
|
||||
_PUSHDOWN_FILTER_STR = ("type", "glob")
|
||||
_PUSHDOWN_FILTER_LIST = ("include", "exclude", "exclude_dir")
|
||||
|
||||
|
||||
def has_search_shaping_flags(flags: Mapping[str, FlagValue] | None) -> bool:
|
||||
@@ -187,6 +243,8 @@ def has_search_shaping_flags(flags: Mapping[str, FlagValue] | None) -> bool:
|
||||
return True
|
||||
if any(fl.as_int(k) is not None for k in _PUSHDOWN_SHAPING_INT):
|
||||
return True
|
||||
if any(fl.as_list(k) for k in _PUSHDOWN_FILTER_LIST):
|
||||
return True
|
||||
return any(fl.as_str(k) is not None for k in _PUSHDOWN_FILTER_STR)
|
||||
|
||||
|
||||
@@ -584,6 +642,7 @@ async def grep_recursive(
|
||||
max_count: int | None,
|
||||
warnings: list[str] | None = None,
|
||||
read_stream_fn=None,
|
||||
filters: WalkFilters = NO_FILTERS,
|
||||
) -> list[str]:
|
||||
results: list[str] = []
|
||||
try:
|
||||
@@ -600,6 +659,8 @@ async def grep_recursive(
|
||||
warnings.append(f"grep: {entry}: {exc}")
|
||||
continue
|
||||
if s.type == FileType.DIRECTORY:
|
||||
if not dir_admitted(entry, filters):
|
||||
continue
|
||||
results.extend(await grep_recursive(
|
||||
readdir_fn,
|
||||
stat_fn,
|
||||
@@ -614,9 +675,12 @@ async def grep_recursive(
|
||||
max_count,
|
||||
warnings,
|
||||
read_stream_fn,
|
||||
filters,
|
||||
))
|
||||
continue
|
||||
if get_extension(entry) in BINARY_EXTENSIONS:
|
||||
if not file_admitted(entry, filters):
|
||||
continue
|
||||
if not filters.text and get_extension(entry) in BINARY_EXTENSIONS:
|
||||
continue
|
||||
if read_stream_fn is not None:
|
||||
try:
|
||||
@@ -769,6 +833,7 @@ async def grep_files_only(
|
||||
basic: bool,
|
||||
warnings: list[str] | None,
|
||||
read_stream_fn=None,
|
||||
filters: WalkFilters = NO_FILTERS,
|
||||
) -> list[str]:
|
||||
compiled = compile_pattern(pattern, ignore_case, fixed_string, whole_word,
|
||||
basic)
|
||||
@@ -804,6 +869,7 @@ async def grep_files_only(
|
||||
max_count,
|
||||
warnings,
|
||||
read_stream_fn,
|
||||
filters,
|
||||
)
|
||||
|
||||
# GNU names a directory operand and moves on without descending into
|
||||
@@ -814,6 +880,9 @@ async def grep_files_only(
|
||||
warnings.append(f"grep: {path}: Is a directory")
|
||||
return []
|
||||
|
||||
if not file_admitted(path, filters):
|
||||
return []
|
||||
|
||||
try:
|
||||
data = await read_bytes_fn(path)
|
||||
except WALK_ERRORS as exc:
|
||||
|
||||
@@ -27,6 +27,7 @@ SPECS: dict[str, CommandSpec] = {
|
||||
Option(short="-v"),
|
||||
# -h archives what a symlink points at instead of the link.
|
||||
Option(short="-h"),
|
||||
Option(short="-O", long="--to-stdout"),
|
||||
Option(short="-f", type="path"),
|
||||
# Every occurrence is kept, in order: GNU chdirs at each
|
||||
# one and fails at the first it cannot enter, so the
|
||||
@@ -35,7 +36,12 @@ SPECS: dict[str, CommandSpec] = {
|
||||
Option(long="--strip-components", type="str"),
|
||||
Option(long="--exclude", type="str"),
|
||||
),
|
||||
rest=Operand(type="path"),
|
||||
# Only -c reads the rest operands from the filesystem. Under -t
|
||||
# and -x each one is a member selector matched against names
|
||||
# inside the archive (the same mode split MountRootPolicy keys
|
||||
# on), so those lines keep them textual, exactly as unzip's
|
||||
# member patterns are.
|
||||
rest=Operand(type="path", text_when=("-x", "-t")),
|
||||
# `tar xzf a.tgz` is the spelling everyone types.
|
||||
old_option_style=True,
|
||||
# -C is a chdir for the operands after it, not a flag the command
|
||||
|
||||
@@ -42,6 +42,13 @@ SPECS: dict[str, CommandSpec] = {
|
||||
Option(short="-C", type="str"),
|
||||
Option(short="-e", type="str", multiple=True),
|
||||
Option(short="-f", type="path", multiple=True),
|
||||
# -a searches the extensions the -r walk skips as binary;
|
||||
# explicit operands are always read as text, which is the
|
||||
# documented divergence (no "binary file matches" rows).
|
||||
Option(short="-a", long="--text"),
|
||||
Option(long="--include", type="str", multiple=True),
|
||||
Option(long="--exclude", type="str", multiple=True),
|
||||
Option(long="--exclude-dir", type="str", multiple=True),
|
||||
# Accepted no-ops: output is never a tty, so plain output is
|
||||
# exactly what GNU produces with --color=auto (#471).
|
||||
Option(long="--color", type="str", value_optional=True),
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from mirage.commands.builtin.generic.tar.mode import is_create_mode
|
||||
from mirage.policy.base import Policy
|
||||
from mirage.policy.types import Action, CommandContext, Deny, MountRootQuery
|
||||
from mirage.types import PathSpec
|
||||
@@ -37,38 +38,6 @@ def has_symlink_flag(argv: tuple[str, ...]) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def is_create_mode(argv: tuple[str, ...]) -> bool:
|
||||
"""Whether a tar line reads the filesystem rather than an archive.
|
||||
|
||||
Only ``-c`` makes tar's operands source paths. Under ``-t`` and
|
||||
``-x`` they are member selectors matched against names inside the
|
||||
archive, so a selector that happens to spell a mount root is not a
|
||||
mount at all and refusing it would deny an ordinary listing.
|
||||
|
||||
Scanned raw for the same reason :func:`has_symlink_flag` is: the
|
||||
policy fires before flag parsing. Only the first word may be GNU's
|
||||
dashless option cluster (``tar cf a.tar d``), so a later bare word
|
||||
is an operand and cannot turn the mode on.
|
||||
|
||||
Args:
|
||||
argv (tuple[str, ...]): raw argv after the command name.
|
||||
"""
|
||||
for i, tok in enumerate(argv):
|
||||
if not isinstance(tok, str):
|
||||
continue
|
||||
if tok == "--create":
|
||||
return True
|
||||
if tok.startswith("--"):
|
||||
continue
|
||||
if tok.startswith("-"):
|
||||
if "c" in tok[1:]:
|
||||
return True
|
||||
continue
|
||||
if i == 0 and "c" in tok:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def has_parents_flag(argv: tuple[str, ...]) -> bool:
|
||||
"""Spot mkdir's -p/--parents by raw token scan.
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ from mirage.workspace.executor.builtins.metadata import (handle_chgrp,
|
||||
from mirage.workspace.executor.builtins.scope import _scope_path, _to_scope
|
||||
from mirage.workspace.executor.builtins.script import (handle_bash,
|
||||
handle_eval,
|
||||
handle_exec_path,
|
||||
handle_sleep,
|
||||
handle_source)
|
||||
from mirage.workspace.executor.builtins.text import (_interpret_escapes,
|
||||
@@ -86,6 +87,7 @@ __all__ = [
|
||||
'handle_set',
|
||||
'handle_shift',
|
||||
'handle_sleep',
|
||||
'handle_exec_path',
|
||||
'handle_source',
|
||||
'handle_test',
|
||||
'handle_timeout',
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import asyncio
|
||||
import math
|
||||
import re
|
||||
import shlex
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
@@ -376,6 +377,79 @@ async def handle_bash(
|
||||
return io.stdout, io, ExecutionNode(command=label, exit_code=io.exit_code)
|
||||
|
||||
|
||||
def _shebang_words(script: str) -> list[str]:
|
||||
"""The interpreter words a script's first line names, env resolved.
|
||||
|
||||
Args:
|
||||
script (str): the script text.
|
||||
"""
|
||||
first = script.split("\n", 1)[0]
|
||||
if not first.startswith("#!"):
|
||||
return []
|
||||
words = first[2:].strip().split()
|
||||
if words and words[0].rsplit("/", 1)[-1] == "env":
|
||||
words = words[1:]
|
||||
return words
|
||||
if words:
|
||||
words[0] = words[0].rsplit("/", 1)[-1]
|
||||
return words
|
||||
|
||||
|
||||
async def handle_exec_path(
|
||||
dispatch: DispatchFn,
|
||||
execute_fn: Callable[..., Any],
|
||||
path: str,
|
||||
args: list[str],
|
||||
session: Session,
|
||||
stdin: ByteSource | None = None,
|
||||
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
|
||||
"""Run a slash-carrying head word as a program, bash's loader rule.
|
||||
|
||||
bash hands a word containing a slash straight to the loader: no
|
||||
builtin, function, or install can claim it, and the file either
|
||||
runs or the shell reports why not. Two deliberate divergences from
|
||||
bash, both consequences of the VFS: there is no exec bit to check
|
||||
(``chmod`` is stored, not enforced; mount mode does real access
|
||||
control), so an existing file runs without ``+x``; and the shell
|
||||
prefix bash puts on the diagnostic is dropped, matching every other
|
||||
mirage diagnostic.
|
||||
|
||||
A shebang naming sh or bash (directly or via env) runs through the
|
||||
nested-shell machinery, as does a script with none. Any other
|
||||
interpreter word is re-dispatched as a command line, so
|
||||
``#!/usr/bin/env python3`` reaches the python3 command wherever the
|
||||
workspace routes it, and an interpreter nobody registers answers
|
||||
with its own "command not found".
|
||||
|
||||
Args:
|
||||
dispatch (DispatchFn): op dispatcher, used to read the file.
|
||||
execute_fn (Callable): runs a program line in this session.
|
||||
path (str): the head word, as typed.
|
||||
args (list[str]): the words after it, positional for the script.
|
||||
session (Session): shell session state.
|
||||
stdin (ByteSource | None): input stream for the script.
|
||||
"""
|
||||
try:
|
||||
script = await read_script_text(dispatch, path, session.cwd)
|
||||
except FS_ERRORS as exc:
|
||||
strerror = fs_strerror(exc)
|
||||
if strerror is None:
|
||||
raise
|
||||
code = 127 if isinstance(exc, FileNotFoundError) else 126
|
||||
return script_error(path, strerror, code)
|
||||
words = _shebang_words(script)
|
||||
interp = words[0] if words else "sh"
|
||||
if interp in ("sh", "bash"):
|
||||
return await handle_bash(dispatch, execute_fn,
|
||||
[*words[1:], path, *args], session, stdin,
|
||||
interp)
|
||||
line = shlex.join([*words, path, *args])
|
||||
io = await execute_fn(line, session_id=session.session_id, stdin=stdin)
|
||||
return io.stdout, io, ExecutionNode(command=f"{path} " +
|
||||
" ".join(args) if args else path,
|
||||
exit_code=io.exit_code)
|
||||
|
||||
|
||||
# Finite non-negative decimals only ("0", "0.2", ".5", "1.", "+1", "1e-3").
|
||||
# GNU sleep additionally accepts "inf" and sleeps forever; an agent shell
|
||||
# must never hang, so non-finite intervals are rejected (deliberate
|
||||
|
||||
@@ -20,6 +20,7 @@ from mirage.commands.builtin.generic.crossmount import (handle_cross_mount,
|
||||
is_cross_mount)
|
||||
from mirage.commands.builtin.generic.crossmount.detect import strategy_for
|
||||
from mirage.commands.builtin.generic.crossmount.types import Strategy
|
||||
from mirage.commands.builtin.generic.tar.mode import is_create_mode
|
||||
from mirage.commands.builtin.utils.limit import maybe_with_timeout
|
||||
from mirage.commands.config import version_request
|
||||
from mirage.commands.spec import SPECS
|
||||
@@ -190,8 +191,13 @@ async def handle_command(
|
||||
stderr=msg.encode())
|
||||
|
||||
# Path-valued flags count: `cp -t /other/mount/dir src` spans mounts
|
||||
# exactly like a positional destination would.
|
||||
if is_cross_mount(cmd_name, routing_scopes, registry):
|
||||
# exactly like a positional destination would. A create-mode tar is
|
||||
# the one relay member kept out: its planner walks a single
|
||||
# backend's tree, so a span there falls through to the refusal
|
||||
# below instead of a relay run that would cross nested mounts.
|
||||
if is_cross_mount(
|
||||
cmd_name, routing_scopes,
|
||||
registry) and not (cmd_name == "tar" and is_create_mode(raw_argv)):
|
||||
# Cross-mount execution bypasses a resource command handler. Parse
|
||||
# against the shared spec so flags and text operands do not depend on
|
||||
# the source mount. The bound single-mount runner lets the strategy
|
||||
|
||||
@@ -47,13 +47,14 @@ from mirage.shell.helpers import ( # isort: skip
|
||||
get_process_sub_direction, get_text, split_env_prefix)
|
||||
from mirage.workspace.executor.builtins import ( # isort: skip
|
||||
accepts_line, follow_paths, handle_bash, handle_cd, handle_chgrp,
|
||||
handle_chmod, handle_chown, handle_command_builtin, handle_df, handle_echo,
|
||||
handle_env, handle_eval, handle_exit, handle_export, handle_getopts,
|
||||
handle_history, handle_ln, handle_local, handle_man, handle_printenv,
|
||||
handle_printf, handle_read, handle_readlink, handle_return, handle_set,
|
||||
handle_shift, handle_sleep, handle_source, handle_test, handle_timeout,
|
||||
handle_touch, handle_trap, handle_type, handle_unset, handle_which,
|
||||
handle_whoami, handle_xargs, link_flags, prepare_mv, strip_link_operands)
|
||||
handle_exec_path, handle_chmod, handle_chown, handle_command_builtin,
|
||||
handle_df, handle_echo, handle_env, handle_eval, handle_exit,
|
||||
handle_export, handle_getopts, handle_history, handle_ln, handle_local,
|
||||
handle_man, handle_printenv, handle_printf, handle_read, handle_readlink,
|
||||
handle_return, handle_set, handle_shift, handle_sleep, handle_source,
|
||||
handle_test, handle_timeout, handle_touch, handle_trap, handle_type,
|
||||
handle_unset, handle_which, handle_whoami, handle_xargs, link_flags,
|
||||
prepare_mv, strip_link_operands)
|
||||
|
||||
_CdArgs = list[str | PathSpec]
|
||||
|
||||
@@ -363,6 +364,15 @@ async def _run_argv(
|
||||
exit_code=deny.exit_code,
|
||||
stderr=err)
|
||||
|
||||
# ── path execution ─────────────────────────
|
||||
# bash hands a slash-carrying head word to the loader, never to
|
||||
# command lookup: no builtin, function, or CLI can claim it. After
|
||||
# the admission gate so a policy sees the line like any other.
|
||||
if name and "/" in name:
|
||||
return await handle_exec_path(dispatch, execute_fn, name,
|
||||
[word_text(a) for a in args], session,
|
||||
stdin)
|
||||
|
||||
# ── unsupported bash builtins ──────────────
|
||||
# Constructs the parser accepts but the executor cannot honor.
|
||||
# Returning a clear error lets LLMs detect a capability gap instead
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
|
||||
from mirage.commands.builtin.generic.archive.extract import (dir_exists,
|
||||
ensure_dir,
|
||||
extract_dest)
|
||||
from mirage.types import FileStat, FileType, PathSpec
|
||||
|
||||
|
||||
def test_extract_dest_prefers_the_explicit_operand():
|
||||
explicit = PathSpec.from_str_path("/work/out")
|
||||
cwd = PathSpec.from_str_path("/elsewhere")
|
||||
assert extract_dest(explicit, cwd, True) == "/work/out"
|
||||
|
||||
|
||||
def test_extract_dest_falls_back_to_cwd_per_space():
|
||||
cwd = PathSpec(virtual="/work/sub",
|
||||
directory="/work/sub",
|
||||
resource_path="sub")
|
||||
assert extract_dest(None, cwd, True) == "/work/sub"
|
||||
assert extract_dest(None, cwd, False) == "/sub"
|
||||
|
||||
|
||||
def test_extract_dest_string_cwd_defaults_to_root():
|
||||
assert extract_dest(None, "", False) == "/"
|
||||
assert extract_dest(None, "/w", True) == "/w"
|
||||
|
||||
|
||||
def _stat_factory(dirs: set[str]):
|
||||
|
||||
async def stat(path: PathSpec) -> FileStat:
|
||||
if path.virtual in dirs:
|
||||
return FileStat(path=path.virtual,
|
||||
name=path.virtual.rsplit("/", 1)[-1],
|
||||
type=FileType.DIRECTORY)
|
||||
raise FileNotFoundError(path.virtual)
|
||||
|
||||
return stat
|
||||
|
||||
|
||||
def test_ensure_dir_creates_only_missing_levels():
|
||||
created: list[str] = []
|
||||
|
||||
async def mkdir(path: PathSpec) -> None:
|
||||
created.append(path.virtual)
|
||||
|
||||
made: set[str] = set()
|
||||
asyncio.run(ensure_dir("/work/a/b", mkdir, _stat_factory({"/work"}), made))
|
||||
assert created == ["/work/a", "/work/a/b"]
|
||||
assert made == {"/work", "/work/a", "/work/a/b"}
|
||||
|
||||
|
||||
def test_ensure_dir_memoizes_across_members():
|
||||
calls: list[str] = []
|
||||
|
||||
async def mkdir(path: PathSpec) -> None:
|
||||
calls.append(path.virtual)
|
||||
|
||||
made: set[str] = set()
|
||||
stat = _stat_factory(set())
|
||||
asyncio.run(ensure_dir("/a/b", mkdir, stat, made))
|
||||
asyncio.run(ensure_dir("/a/b/c", mkdir, stat, made))
|
||||
assert calls == ["/a", "/a/b", "/a/b/c"]
|
||||
|
||||
|
||||
def test_dir_exists_answers_false_on_a_miss():
|
||||
stat = _stat_factory(set())
|
||||
assert asyncio.run(dir_exists(stat, "/nope")) is False
|
||||
stat = _stat_factory({"/yes"})
|
||||
assert asyncio.run(dir_exists(stat, "/yes")) is True
|
||||
@@ -0,0 +1,34 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.commands.builtin.generic.tar.mode import is_create_mode
|
||||
|
||||
|
||||
def test_dash_c_and_long_create_turn_the_mode_on():
|
||||
assert is_create_mode(("-czf", "out.tgz", "dir"))
|
||||
assert is_create_mode(("--create", "-f", "out.tar", "dir"))
|
||||
|
||||
|
||||
def test_extract_and_list_stay_off():
|
||||
assert not is_create_mode(("-xzf", "a.tgz", "./m/x.json"))
|
||||
assert not is_create_mode(("-tzf", "a.tgz"))
|
||||
|
||||
|
||||
def test_only_the_first_word_may_be_a_dashless_cluster():
|
||||
assert is_create_mode(("cf", "a.tar", "d"))
|
||||
assert not is_create_mode(("-xf", "a.tar", "crate"))
|
||||
|
||||
|
||||
def test_long_options_never_read_as_clusters():
|
||||
assert not is_create_mode(("--exclude", "c", "-xf", "a.tar"))
|
||||
@@ -58,11 +58,11 @@ class _Recorder:
|
||||
def __init__(self) -> None:
|
||||
self.written: dict[str, bytes] = {}
|
||||
|
||||
async def __call__(self, p: PathSpec, content: bytes) -> None:
|
||||
self.written[p.virtual] = content
|
||||
async def __call__(self, p: PathSpec, data: bytes) -> None:
|
||||
self.written[p.virtual] = data
|
||||
|
||||
|
||||
async def _no_write(_p: PathSpec, _content: bytes) -> None:
|
||||
async def _no_write(_p: PathSpec, data: bytes) -> None:
|
||||
raise AssertionError("write_bytes must not be called")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
|
||||
|
||||
def _seed(env) -> None:
|
||||
env.create_file("notes.tex", b"score 9\n")
|
||||
env.create_file("notes.txt", b"score 8\n")
|
||||
env.create_file("sub/inner.tex", b"score 7\n")
|
||||
|
||||
|
||||
def test_include_filters_the_recursive_walk(env):
|
||||
_seed(env)
|
||||
out = env.mirage("grep -RInE --include='*.tex' score /data")
|
||||
assert out == ("/data/notes.tex:1:score 9\n"
|
||||
"/data/sub/inner.tex:1:score 7\n")
|
||||
|
||||
|
||||
def test_exclude_wins_over_include(env):
|
||||
_seed(env)
|
||||
result = asyncio.run(
|
||||
env.ws.execute(
|
||||
"grep -r --include='*.tex' --exclude='notes.*' score /data"))
|
||||
assert result.exit_code == 0
|
||||
assert result.stdout == b"/data/sub/inner.tex:score 7\n"
|
||||
|
||||
|
||||
def test_exclude_dir_prunes_the_walk(env):
|
||||
_seed(env)
|
||||
out = env.mirage("grep -r --include='*.tex' --exclude-dir=sub score /data")
|
||||
assert out == "/data/notes.tex:score 9\n"
|
||||
|
||||
|
||||
def test_include_with_a_slash_matches_nothing(env):
|
||||
_seed(env)
|
||||
result = asyncio.run(
|
||||
env.ws.execute("grep -r --include='sub/*.tex' score /data"))
|
||||
assert result.exit_code == 1
|
||||
assert result.stdout in (None, b"", b"\n") or not result.stdout
|
||||
|
||||
|
||||
def test_include_filters_an_explicit_operand_in_silence(env):
|
||||
_seed(env)
|
||||
result = asyncio.run(
|
||||
env.ws.execute("grep --include='*.tex' -n score /data/notes.txt"))
|
||||
assert result.exit_code == 1
|
||||
assert not result.stderr
|
||||
|
||||
|
||||
def test_dash_a_reads_binary_extensions_in_the_walk(env):
|
||||
_seed(env)
|
||||
env.create_file("data.parquet", b"score binary\n")
|
||||
without = env.mirage("grep -r score /data")
|
||||
assert "data.parquet" not in without
|
||||
with_a = env.mirage("grep -ra score /data")
|
||||
assert "/data/data.parquet:score binary" in with_a
|
||||
|
||||
|
||||
def test_dash_a_is_accepted_on_an_explicit_operand(env):
|
||||
_seed(env)
|
||||
out = env.mirage("grep -aoiE 'SCORE' /data/notes.tex")
|
||||
assert out == "score\n"
|
||||
@@ -0,0 +1,101 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
import gzip
|
||||
import io
|
||||
import tarfile
|
||||
|
||||
|
||||
def _tgz_bytes() -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with tarfile.open(fileobj=buf, mode="w") as tf:
|
||||
folder = tarfile.TarInfo(name="./memory")
|
||||
folder.type = tarfile.DIRTYPE
|
||||
tf.addfile(folder)
|
||||
for name in ("./memory/memory.json", "./other.txt"):
|
||||
info = tarfile.TarInfo(name=name)
|
||||
data = f"content:{name}\n".encode()
|
||||
info.size = len(data)
|
||||
tf.addfile(info, io.BytesIO(data))
|
||||
return gzip.compress(buf.getvalue())
|
||||
|
||||
|
||||
def _seed_archive(env) -> None:
|
||||
env.mirage("tee /data/files.tar.gz", stdin=_tgz_bytes())
|
||||
|
||||
|
||||
def test_tar_t_selector_lists_only_the_member(env):
|
||||
_seed_archive(env)
|
||||
out = env.mirage("tar -tzf /data/files.tar.gz ./memory/memory.json")
|
||||
assert out == "./memory/memory.json\n"
|
||||
|
||||
|
||||
def test_tar_t_dir_selector_takes_the_subtree(env):
|
||||
_seed_archive(env)
|
||||
out = env.mirage("tar -tzf /data/files.tar.gz ./memory")
|
||||
assert out == "./memory/\n./memory/memory.json\n"
|
||||
|
||||
|
||||
def test_tar_t_selector_matches_stored_spelling_only(env):
|
||||
_seed_archive(env)
|
||||
result = asyncio.run(
|
||||
env.ws.execute("tar -tzf /data/files.tar.gz memory/memory.json"))
|
||||
assert result.exit_code == 2
|
||||
stderr = result.stderr.decode()
|
||||
assert "tar: memory/memory.json: Not found in archive" in stderr
|
||||
assert "Exiting with failure status due to previous errors" in stderr
|
||||
|
||||
|
||||
def test_tar_xO_streams_member_bytes_to_stdout(env):
|
||||
_seed_archive(env)
|
||||
out = env.mirage("tar -xOzf /data/files.tar.gz ./memory/memory.json")
|
||||
assert out == "content:./memory/memory.json\n"
|
||||
listing = env.mirage("find /data -name 'memory.json'")
|
||||
assert listing.strip() == ""
|
||||
|
||||
|
||||
def test_tar_xvO_lists_names_on_stderr(env):
|
||||
_seed_archive(env)
|
||||
result = asyncio.run(
|
||||
env.ws.execute("tar -xvOzf /data/files.tar.gz ./memory/memory.json"))
|
||||
assert result.exit_code == 0
|
||||
assert result.stderr.decode() == "./memory/memory.json\n"
|
||||
|
||||
|
||||
def test_tar_x_selector_extracts_only_the_member(env):
|
||||
_seed_archive(env)
|
||||
env.mirage("mkdir -p /data/out")
|
||||
env.mirage("tar -xzf /data/files.tar.gz -C /data/out ./other.txt")
|
||||
assert env.mirage("cat /data/out/other.txt") == "content:./other.txt\n"
|
||||
assert env.mirage("find /data/out -name 'memory.json'").strip() == ""
|
||||
|
||||
|
||||
def test_tar_x_mixed_hit_and_miss_extracts_and_exits_2(env):
|
||||
_seed_archive(env)
|
||||
env.mirage("mkdir -p /data/out2")
|
||||
result = asyncio.run(
|
||||
env.ws.execute(
|
||||
"tar -xzf /data/files.tar.gz -C /data/out2 ./other.txt nope"))
|
||||
assert result.exit_code == 2
|
||||
assert "tar: nope: Not found in archive" in result.stderr.decode()
|
||||
assert env.mirage("cat /data/out2/other.txt") == "content:./other.txt\n"
|
||||
|
||||
|
||||
def test_tar_x_extracts_into_cwd_with_dot_components_cleaned(env):
|
||||
_seed_archive(env)
|
||||
env.mirage("tar -xzf /data/files.tar.gz")
|
||||
assert env.mirage(
|
||||
"cat /data/memory/memory.json") == "content:./memory/memory.json\n"
|
||||
assert "/data/./memory" not in env.mirage("find /data")
|
||||
@@ -0,0 +1,98 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.resource import RAMResource
|
||||
from mirage.types import MountMode
|
||||
from mirage.workspace import Workspace
|
||||
from mirage.workspace.executor.builtins.script import _shebang_words
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def ws() -> Workspace:
|
||||
return Workspace(
|
||||
resources={
|
||||
"/": (RAMResource(), MountMode.WRITE),
|
||||
"/work/": (RAMResource(), MountMode.WRITE),
|
||||
})
|
||||
|
||||
|
||||
def _run(ws: Workspace, line: str):
|
||||
return asyncio.run(ws.execute(line))
|
||||
|
||||
|
||||
def test_slash_head_word_runs_the_file(ws):
|
||||
_run(ws, "printf 'echo ran\\n' > /work/run.sh")
|
||||
result = _run(ws, "/work/run.sh")
|
||||
assert result.exit_code == 0
|
||||
assert result.stdout == b"ran\n"
|
||||
|
||||
|
||||
def test_relative_path_resolves_against_cwd(ws):
|
||||
_run(ws, "printf 'echo rel\\n' > /work/run.sh")
|
||||
result = _run(ws, "cd /work && ./run.sh")
|
||||
assert result.stdout == b"rel\n"
|
||||
|
||||
|
||||
def test_script_gets_dollar_zero_and_positionals(ws):
|
||||
# A line-final positional hits a pre-existing expansion bug that
|
||||
# `sh FILE` shows identically, so the line ends on a literal and
|
||||
# this pins only what path execution adds.
|
||||
_run(ws, "printf 'echo $0:$1:end\\n' > /work/args.sh")
|
||||
result = _run(ws, "/work/args.sh a b")
|
||||
assert result.stdout == b"/work/args.sh:a:end\n"
|
||||
|
||||
|
||||
def test_shebang_interpreter_options_apply(ws):
|
||||
_run(ws, "printf '#!/bin/bash -x\\necho traced\\n' > /work/t.sh")
|
||||
result = _run(ws, "/work/t.sh")
|
||||
assert result.stdout == b"traced\n"
|
||||
assert result.stderr == b"+ echo traced\n"
|
||||
|
||||
|
||||
def test_missing_file_is_127(ws):
|
||||
result = _run(ws, "/work/nope.sh")
|
||||
assert result.exit_code == 127
|
||||
assert result.stderr == b"/work/nope.sh: No such file or directory\n"
|
||||
|
||||
|
||||
def test_directory_is_126(ws):
|
||||
_run(ws, "mkdir -p /work/adir")
|
||||
result = _run(ws, "/work/adir")
|
||||
assert result.exit_code == 126
|
||||
assert result.stderr == b"/work/adir: Is a directory\n"
|
||||
|
||||
|
||||
def test_unknown_interpreter_reports_command_not_found(ws):
|
||||
_run(ws, "printf '#!/usr/bin/env ruby\\nputs 1\\n' > /work/r.rb")
|
||||
result = _run(ws, "/work/r.rb")
|
||||
assert result.exit_code == 127
|
||||
assert result.stderr == b"ruby: command not found\n"
|
||||
|
||||
|
||||
def test_child_shell_state_does_not_leak(ws):
|
||||
_run(ws, "printf 'cd /work\\nexit 3\\n' > /child.sh")
|
||||
result = _run(ws, "/child.sh; echo \"$?:$(pwd)\"")
|
||||
assert result.stdout == b"3:/\n"
|
||||
|
||||
|
||||
def test_shebang_words_resolves_env_and_basenames():
|
||||
assert _shebang_words("#!/bin/sh\necho hi\n") == ["sh"]
|
||||
assert _shebang_words("#!/usr/bin/env bash\n") == ["bash"]
|
||||
assert _shebang_words("#!/bin/bash -x\n") == ["bash", "-x"]
|
||||
assert _shebang_words("#!/usr/bin/env python3\n") == ["python3"]
|
||||
assert _shebang_words("echo no shebang\n") == []
|
||||
@@ -0,0 +1,102 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
import gzip
|
||||
import io
|
||||
import tarfile
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.resource import RAMResource
|
||||
from mirage.types import MountMode
|
||||
from mirage.workspace import Workspace
|
||||
|
||||
|
||||
def _tgz_bytes() -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with tarfile.open(fileobj=buf, mode="w") as tf:
|
||||
for name in ("./memory/memory.json", "./other.txt"):
|
||||
info = tarfile.TarInfo(name=name)
|
||||
data = f"content:{name}\n".encode()
|
||||
info.size = len(data)
|
||||
tf.addfile(info, io.BytesIO(data))
|
||||
return gzip.compress(buf.getvalue())
|
||||
|
||||
|
||||
def _zip_bytes() -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("memory/memory.json", "zipped\n")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def ws() -> Workspace:
|
||||
work = RAMResource()
|
||||
work._store.files["/files.tar.gz"] = _tgz_bytes()
|
||||
work._store.files["/files.zip"] = _zip_bytes()
|
||||
return Workspace(resources={
|
||||
"/": (RAMResource(), MountMode.WRITE),
|
||||
"/work/": (work, MountMode.WRITE),
|
||||
})
|
||||
|
||||
|
||||
def _run(ws: Workspace, line: str):
|
||||
return asyncio.run(ws.execute(line))
|
||||
|
||||
|
||||
def test_tar_selector_does_not_join_routing(ws):
|
||||
# cwd is /, the archive is on /work: the selector must not count as
|
||||
# a path operand or the line refuses as a cross-mount span.
|
||||
result = _run(ws, "tar -xOzf /work/files.tar.gz ./memory/memory.json")
|
||||
assert result.exit_code == 0
|
||||
assert result.stdout == b"content:./memory/memory.json\n"
|
||||
|
||||
|
||||
def test_tar_extract_lands_in_cwd_across_mounts(ws):
|
||||
result = _run(ws, "tar -xzf /work/files.tar.gz")
|
||||
assert result.exit_code == 0
|
||||
out = _run(ws, "cat /memory/memory.json")
|
||||
assert out.stdout == b"content:./memory/memory.json\n"
|
||||
|
||||
|
||||
def test_tar_extract_dash_C_into_another_mount(ws):
|
||||
result = _run(ws, "tar -xzf /work/files.tar.gz -C /dest")
|
||||
assert result.exit_code == 0
|
||||
out = _run(ws, "cat /dest/memory/memory.json")
|
||||
assert out.stdout == b"content:./memory/memory.json\n"
|
||||
|
||||
|
||||
def test_tar_create_span_keeps_the_refusal(ws):
|
||||
_run(ws, "mkdir -p /src && echo hi > /src/f.txt")
|
||||
result = _run(ws, "tar -czf /work/backup.tgz /src")
|
||||
assert result.exit_code == 1
|
||||
assert b"paths span multiple mounts" in result.stderr
|
||||
|
||||
|
||||
def test_unzip_extracts_into_cwd(ws):
|
||||
_run(ws, "cd /")
|
||||
result = _run(ws, "unzip -q /work/files.zip")
|
||||
assert result.exit_code == 0
|
||||
out = _run(ws, "cat /memory/memory.json")
|
||||
assert out.stdout == b"zipped\n"
|
||||
|
||||
|
||||
def test_unzip_dash_d_into_another_mount(ws):
|
||||
result = _run(ws, "unzip -q -d /dest /work/files.zip")
|
||||
assert result.exit_code == 0
|
||||
out = _run(ws, "cat /dest/memory/memory.json")
|
||||
assert out.stdout == b"zipped\n"
|
||||
@@ -352,6 +352,26 @@
|
||||
"short": "-f",
|
||||
"type": "path"
|
||||
},
|
||||
{
|
||||
"long": "--text",
|
||||
"short": "-a",
|
||||
"type": "bool"
|
||||
},
|
||||
{
|
||||
"long": "--include",
|
||||
"multiple": true,
|
||||
"type": "str"
|
||||
},
|
||||
{
|
||||
"long": "--exclude",
|
||||
"multiple": true,
|
||||
"type": "str"
|
||||
},
|
||||
{
|
||||
"long": "--exclude-dir",
|
||||
"multiple": true,
|
||||
"type": "str"
|
||||
},
|
||||
{
|
||||
"long": "--color",
|
||||
"type": "str",
|
||||
|
||||
@@ -142,6 +142,11 @@
|
||||
"short": "-h",
|
||||
"type": "bool"
|
||||
},
|
||||
{
|
||||
"long": "--to-stdout",
|
||||
"short": "-O",
|
||||
"type": "bool"
|
||||
},
|
||||
{
|
||||
"short": "-f",
|
||||
"type": "path"
|
||||
@@ -161,6 +166,10 @@
|
||||
}
|
||||
],
|
||||
"rest": {
|
||||
"text_when": [
|
||||
"-x",
|
||||
"-t"
|
||||
],
|
||||
"type": "path"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,6 +310,26 @@
|
||||
"short": "-f",
|
||||
"type": "path"
|
||||
},
|
||||
{
|
||||
"long": "--text",
|
||||
"short": "-a",
|
||||
"type": "bool"
|
||||
},
|
||||
{
|
||||
"long": "--include",
|
||||
"multiple": true,
|
||||
"type": "str"
|
||||
},
|
||||
{
|
||||
"long": "--exclude",
|
||||
"multiple": true,
|
||||
"type": "str"
|
||||
},
|
||||
{
|
||||
"long": "--exclude-dir",
|
||||
"multiple": true,
|
||||
"type": "str"
|
||||
},
|
||||
{
|
||||
"long": "--color",
|
||||
"type": "str",
|
||||
|
||||
@@ -107,6 +107,11 @@
|
||||
"short": "-h",
|
||||
"type": "bool"
|
||||
},
|
||||
{
|
||||
"long": "--to-stdout",
|
||||
"short": "-O",
|
||||
"type": "bool"
|
||||
},
|
||||
{
|
||||
"short": "-f",
|
||||
"type": "path"
|
||||
@@ -126,6 +131,10 @@
|
||||
}
|
||||
],
|
||||
"rest": {
|
||||
"text_when": [
|
||||
"-x",
|
||||
"-t"
|
||||
],
|
||||
"type": "path"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,6 +373,26 @@
|
||||
"short": "-f",
|
||||
"type": "path"
|
||||
},
|
||||
{
|
||||
"long": "--text",
|
||||
"short": "-a",
|
||||
"type": "bool"
|
||||
},
|
||||
{
|
||||
"long": "--include",
|
||||
"multiple": true,
|
||||
"type": "str"
|
||||
},
|
||||
{
|
||||
"long": "--exclude",
|
||||
"multiple": true,
|
||||
"type": "str"
|
||||
},
|
||||
{
|
||||
"long": "--exclude-dir",
|
||||
"multiple": true,
|
||||
"type": "str"
|
||||
},
|
||||
{
|
||||
"long": "--color",
|
||||
"type": "str",
|
||||
|
||||
@@ -163,6 +163,11 @@
|
||||
"short": "-h",
|
||||
"type": "bool"
|
||||
},
|
||||
{
|
||||
"long": "--to-stdout",
|
||||
"short": "-O",
|
||||
"type": "bool"
|
||||
},
|
||||
{
|
||||
"short": "-f",
|
||||
"type": "path"
|
||||
@@ -182,6 +187,10 @@
|
||||
}
|
||||
],
|
||||
"rest": {
|
||||
"text_when": [
|
||||
"-x",
|
||||
"-t"
|
||||
],
|
||||
"type": "path"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import { type FileStat, FileType, PathSpec } from '../../../../types.ts'
|
||||
import { mountKey } from '../../../../utils/key_prefix.ts'
|
||||
|
||||
export type StatDoor = (p: PathSpec) => Promise<FileStat>
|
||||
export type MkdirDoor = (p: PathSpec, parents?: boolean) => Promise<void>
|
||||
|
||||
/**
|
||||
* Where extraction lands: the explicit operand, else the cwd.
|
||||
*
|
||||
* The explicit operand is tar's last -C or unzip's -d; both arrive as
|
||||
* resolved virtual-path strings in the TypeScript flag bag, and the cwd
|
||||
* is virtual too, so no per-door-space split exists here the way it
|
||||
* does in Python (whose accessor doors speak mount-relative paths).
|
||||
*/
|
||||
export function extractDest(explicit: string | null, cwd: string): string {
|
||||
const target = explicit ?? cwd
|
||||
return target !== '' ? target : '/'
|
||||
}
|
||||
|
||||
export function flatPathSpec(virtual: string): PathSpec {
|
||||
return new PathSpec({
|
||||
virtual,
|
||||
directory: virtual,
|
||||
resourcePath: mountKey(virtual, ''),
|
||||
resolved: true,
|
||||
})
|
||||
}
|
||||
|
||||
async function dirExists(stat: StatDoor, level: PathSpec): Promise<boolean> {
|
||||
try {
|
||||
return (await stat(level)).type === FileType.DIRECTORY
|
||||
} catch {
|
||||
// A stat miss here only means "not standing yet"; mkdir answers.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create one directory chain top-down, skipping what exists.
|
||||
*
|
||||
* The dispatch mkdir op is single-level on most backends (the
|
||||
* `mkdirParents` knob is a per-backend exception), so extraction walks
|
||||
* the chain itself the way relay cp does: probe, then create only what
|
||||
* is missing, memoized per run so an archive of many files stats each
|
||||
* ancestor once.
|
||||
*/
|
||||
export async function ensureDir(
|
||||
dirPath: string,
|
||||
toSpec: (virtual: string) => PathSpec,
|
||||
mkdir: MkdirDoor,
|
||||
stat: StatDoor,
|
||||
made: Set<string>,
|
||||
): Promise<void> {
|
||||
const parts = dirPath.split('/').filter((part) => part !== '')
|
||||
for (let i = 1; i <= parts.length; i += 1) {
|
||||
const level = `/${parts.slice(0, i).join('/')}`
|
||||
if (made.has(level)) continue
|
||||
if (!(await dirExists(stat, toSpec(level)))) {
|
||||
await mkdir(toSpec(level))
|
||||
}
|
||||
made.add(level)
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,8 @@ export const RELAY_COMMANDS: ReadonlySet<string> = new Set([
|
||||
Cmd.PASTE,
|
||||
Cmd.COMM,
|
||||
Cmd.JOIN,
|
||||
Cmd.TAR,
|
||||
Cmd.UNZIP,
|
||||
])
|
||||
export const CROSS_MOUNT_COMMANDS: ReadonlySet<string> = new Set([
|
||||
...STREAM_COMMANDS,
|
||||
|
||||
@@ -20,6 +20,8 @@ import { runDiff } from './diff.ts'
|
||||
import { runJoin } from './join.ts'
|
||||
import { runMv } from './mv.ts'
|
||||
import { runPaste } from './paste.ts'
|
||||
import { runTar } from './tar.ts'
|
||||
import { runUnzip } from './unzip.ts'
|
||||
import { Cmd, type CrossResult, type DispatchFn } from '../types.ts'
|
||||
import type { FlagValue } from '../../../../spec/types.ts'
|
||||
|
||||
@@ -30,6 +32,7 @@ import type { FlagValue } from '../../../../spec/types.ts'
|
||||
export async function runRelay(
|
||||
cmdName: Cmd,
|
||||
scopes: PathSpec[],
|
||||
textArgs: string[],
|
||||
flagKwargs: Record<string, FlagValue>,
|
||||
dispatch: DispatchFn,
|
||||
// Maps an operand to its storage identity, for the transfer commands
|
||||
@@ -43,5 +46,7 @@ export async function runRelay(
|
||||
if (cmdName === Cmd.PASTE) return runPaste(scopes, flagKwargs, dispatch)
|
||||
if (cmdName === Cmd.COMM) return runComm(scopes, flagKwargs, dispatch)
|
||||
if (cmdName === Cmd.JOIN) return runJoin(scopes, flagKwargs, dispatch)
|
||||
if (cmdName === Cmd.TAR) return runTar(textArgs, flagKwargs, dispatch)
|
||||
if (cmdName === Cmd.UNZIP) return runUnzip(scopes, textArgs, flagKwargs, dispatch)
|
||||
return runCmp(scopes, flagKwargs, dispatch)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import { IOResult } from '../../../../../io/types.ts'
|
||||
import { tarGeneric } from '../../tar.ts'
|
||||
import { crossOpts, statOp, streamOp } from '../utils.ts'
|
||||
import type { CrossResult, DispatchFn } from '../types.ts'
|
||||
import type { FlagValue } from '../../../../spec/types.ts'
|
||||
|
||||
/**
|
||||
* Run a -t/-x tar whose archive and -C destination span mounts.
|
||||
*
|
||||
* Pure wiring: the shared generic runs on dispatch-relayed doors, so
|
||||
* the archive is read from its mount and every extracted path lands on
|
||||
* whichever mount owns it. Create mode never reaches here: the executor
|
||||
* keeps a create-mode span on the plain refusal, because its planner
|
||||
* walks one backend's tree and relay doors would cross nested mount
|
||||
* boundaries the planner is required to refuse.
|
||||
*/
|
||||
export async function runTar(
|
||||
textArgs: string[],
|
||||
flagKwargs: Record<string, FlagValue>,
|
||||
dispatch: DispatchFn,
|
||||
): Promise<CrossResult> {
|
||||
const result = await tarGeneric(
|
||||
[],
|
||||
textArgs,
|
||||
crossOpts(flagKwargs),
|
||||
{
|
||||
stream: streamOp(dispatch),
|
||||
write: async (p, data) => {
|
||||
await dispatch('write', p, [data])
|
||||
},
|
||||
mkdir: async (p) => {
|
||||
await dispatch('mkdir', p)
|
||||
},
|
||||
stat: statOp(dispatch),
|
||||
walk: () => Promise.resolve([]),
|
||||
isDir: () => Promise.resolve(false),
|
||||
},
|
||||
true,
|
||||
)
|
||||
return result ?? [null, new IOResult()]
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import { specOf } from '../../../../spec/builtins.ts'
|
||||
import { FlagView } from '../../../../spec/types.ts'
|
||||
import type { PathSpec } from '../../../../../types.ts'
|
||||
import { IOResult } from '../../../../../io/types.ts'
|
||||
import { unzipGeneric } from '../../unzip.ts'
|
||||
import { crossOpts, statOp, streamOp } from '../utils.ts'
|
||||
import type { CrossResult, DispatchFn } from '../types.ts'
|
||||
import type { FlagValue } from '../../../../spec/types.ts'
|
||||
|
||||
/**
|
||||
* Run an unzip whose archive and -d destination span mounts.
|
||||
*
|
||||
* Pure wiring: the shared generic runs on dispatch-relayed doors, so
|
||||
* the archive is read from its mount and every extracted path lands on
|
||||
* whichever mount owns it.
|
||||
*/
|
||||
export async function runUnzip(
|
||||
scopes: PathSpec[],
|
||||
textArgs: string[],
|
||||
flagKwargs: Record<string, FlagValue>,
|
||||
dispatch: DispatchFn,
|
||||
): Promise<CrossResult> {
|
||||
const fl = new FlagView(flagKwargs, specOf('unzip'))
|
||||
// Scopes arrive in line order and include the -d flag's value, so the
|
||||
// archive is the first scope that is not the destination.
|
||||
const dest = fl.asStr('d')
|
||||
const operands = scopes.filter((s) => s.virtual !== dest)
|
||||
const result = await unzipGeneric(
|
||||
operands.length > 0 ? operands : scopes,
|
||||
textArgs,
|
||||
crossOpts(flagKwargs),
|
||||
streamOp(dispatch),
|
||||
async (p, data) => {
|
||||
await dispatch('write', p, [data])
|
||||
},
|
||||
async (p) => {
|
||||
await dispatch('mkdir', p)
|
||||
},
|
||||
statOp(dispatch),
|
||||
true,
|
||||
)
|
||||
return result ?? [null, new IOResult()]
|
||||
}
|
||||
@@ -47,7 +47,7 @@ export async function handleCrossMount(
|
||||
const cmd = cmdName as Cmd
|
||||
const strategy = strategyFor(cmd, flagKwargs)
|
||||
if (strategy === Strategy.RELAY) {
|
||||
return await runRelay(cmd, scopes, flagKwargs, dispatch, storageKey)
|
||||
return await runRelay(cmd, scopes, textArgs, flagKwargs, dispatch, storageKey)
|
||||
}
|
||||
if (strategy === Strategy.STREAM) {
|
||||
return await runStream(cmd, scopes, textArgs, flagKwargs, runSingle)
|
||||
|
||||
@@ -74,6 +74,8 @@ export enum Cmd {
|
||||
PASTE = 'paste',
|
||||
COMM = 'comm',
|
||||
JOIN = 'join',
|
||||
TAR = 'tar',
|
||||
UNZIP = 'unzip',
|
||||
}
|
||||
|
||||
export type { DispatchFn } from '../../../../runtime/types.ts'
|
||||
|
||||
@@ -27,10 +27,12 @@ import {
|
||||
countExitStream,
|
||||
countRecordsHaveMatches,
|
||||
exitCodeFor,
|
||||
fileAdmitted,
|
||||
grepFilesOnly,
|
||||
type GrepFilesOnlyOptions,
|
||||
grepLines,
|
||||
grepRecursive,
|
||||
type WalkFilters,
|
||||
grepStream,
|
||||
prefixLines,
|
||||
resolvePatternFromFlags,
|
||||
@@ -45,6 +47,7 @@ type Readdir = (p: PathSpec) => Promise<string[]>
|
||||
type Stream = (p: PathSpec) => AsyncIterable<Uint8Array>
|
||||
|
||||
interface FlagSet {
|
||||
filters: WalkFilters
|
||||
ignoreCase: boolean
|
||||
invert: boolean
|
||||
lineNumbers: boolean
|
||||
@@ -67,6 +70,12 @@ function parseFlags(fl: FlagView): FlagSet {
|
||||
const bCtx = fl.asInt('B')
|
||||
const cCtx = fl.asInt('C')
|
||||
return {
|
||||
filters: {
|
||||
include: fl.asList('include'),
|
||||
exclude: fl.asList('exclude'),
|
||||
excludeDir: fl.asList('exclude_dir'),
|
||||
text: fl.asBool('text'),
|
||||
},
|
||||
ignoreCase: fl.asBool('i'),
|
||||
invert: fl.asBool('v'),
|
||||
lineNumbers: fl.asBool('n'),
|
||||
@@ -104,6 +113,7 @@ function makeSpec(path: string, template: PathSpec): PathSpec {
|
||||
function filesOnlyOpts(f: FlagSet, recursive: boolean): GrepFilesOnlyOptions {
|
||||
return {
|
||||
recursive,
|
||||
filters: f.filters,
|
||||
ignoreCase: f.ignoreCase,
|
||||
invert: f.invert,
|
||||
lineNumbers: f.lineNumbers,
|
||||
@@ -222,6 +232,8 @@ export async function grepGeneric(
|
||||
false,
|
||||
)
|
||||
for (const r of respellRaw(res, p.virtual, p.rawPath)) allResults.push(r)
|
||||
} else if (!fileAdmitted(p.virtual, f.filters)) {
|
||||
continue
|
||||
} else {
|
||||
const data = splitLinesNoTrailing(DEC.decode(await readBytesFn(p.virtual)))
|
||||
const hits = grepLines(p.rawPath, data, pat, f)
|
||||
@@ -263,6 +275,7 @@ export async function grepGeneric(
|
||||
multiWarnings.push(`${name}: ${p.rawPath}: Is a directory`)
|
||||
continue
|
||||
}
|
||||
if (!fileAdmitted(p.virtual, f.filters)) continue
|
||||
const data = splitLinesNoTrailing(DEC.decode(await materialize(stream(p))))
|
||||
const hits = grepLines(p.rawPath, data, pat, f)
|
||||
const label = f.noFilename ? '' : `${p.rawPath}:`
|
||||
@@ -320,6 +333,11 @@ export async function grepGeneric(
|
||||
}),
|
||||
]
|
||||
}
|
||||
if (!fileAdmitted(first.virtual, f.filters)) {
|
||||
// GNU passes over a command-line file --include leaves out in
|
||||
// silence: no output, no diagnostic, exit "no match".
|
||||
return [new Uint8Array(0), new IOResult({ exitCode: 1 })]
|
||||
}
|
||||
const source = stream(first)
|
||||
const matched = grepStream(source, pat, f)
|
||||
if (f.quiet) {
|
||||
|
||||
@@ -23,10 +23,77 @@ import { readTar, writeTar, type TarEntry } from '../tar_helper.ts'
|
||||
import { rstripSlash } from '../../../utils/slash.ts'
|
||||
import { COMPRESSION_SIGNATURES } from './tar/constants.ts'
|
||||
import { planCreate, type DirProbe, type StatFn, type WalkFn } from './tar/create.ts'
|
||||
import { ensureDir, extractDest } from './archive/extract.ts'
|
||||
import type { Compression, CompressionKind, CreateResult } from './tar/types.ts'
|
||||
|
||||
const ENC = new TextEncoder()
|
||||
|
||||
export const MISS_TRAILER = 'tar: Exiting with failure status due to previous errors'
|
||||
const DOTDOT_NOTICE = "tar: Removing leading `../' from member names"
|
||||
|
||||
/**
|
||||
* Whether one -t/-x member selector keeps an archive member.
|
||||
*
|
||||
* GNU matches the stored spelling exactly (`memory/x` does not find
|
||||
* `./memory/x`), and a selector naming a directory takes its whole
|
||||
* subtree, with or without the trailing slash.
|
||||
*/
|
||||
function matchesSelector(name: string, selector: string): boolean {
|
||||
const base = rstripSlash(selector)
|
||||
const trimmed = rstripSlash(name)
|
||||
return trimmed === base || trimmed.startsWith(`${base}/`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Member indices the selectors keep, and the misses they report.
|
||||
*
|
||||
* No selector keeps everything. A selector that matches nothing is
|
||||
* GNU's per-operand diagnostic, reported in operand order; the caller
|
||||
* appends the one failure trailer.
|
||||
*/
|
||||
function selectedMembers(
|
||||
names: readonly string[],
|
||||
selectors: readonly string[],
|
||||
): { keep: Set<number>; misses: string[] } {
|
||||
if (selectors.length === 0) {
|
||||
return { keep: new Set(names.map((_, index) => index)), misses: [] }
|
||||
}
|
||||
const keep = new Set<number>()
|
||||
const misses: string[] = []
|
||||
for (const selector of selectors) {
|
||||
let hit = false
|
||||
for (const [index, name] of names.entries()) {
|
||||
if (matchesSelector(name, selector)) {
|
||||
keep.add(index)
|
||||
hit = true
|
||||
}
|
||||
}
|
||||
if (!hit) misses.push(`tar: ${selector}: Not found in archive`)
|
||||
}
|
||||
return { keep, misses }
|
||||
}
|
||||
|
||||
/**
|
||||
* The destination-relative components one member extracts to.
|
||||
*
|
||||
* GNU strips --strip-components off the stored spelling first, in which
|
||||
* a leading `.` counts as a component (--strip-components=1 turns
|
||||
* `./a/b` into `a/b`). Only then is the remainder cleaned for the
|
||||
* filesystem: `.` components vanish (a real OS resolves them; a virtual
|
||||
* path must not keep a literal `.` directory) and a leading `..` is
|
||||
* removed with GNU's one notice per run.
|
||||
*/
|
||||
function outParts(name: string, stripN: number, notices: string[]): string[] {
|
||||
let parts = rstripSlash(name).split('/')
|
||||
if (stripN > 0) parts = parts.slice(stripN)
|
||||
parts = parts.filter((part) => part !== '' && part !== '.')
|
||||
while (parts.length > 0 && parts[0] === '..') {
|
||||
if (!notices.includes(DOTDOT_NOTICE)) notices.push(DOTDOT_NOTICE)
|
||||
parts.shift()
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
// What tar needs from the mount it runs on. `stat` and `walk` are what
|
||||
// make a directory operand archivable at all; `isDir` answers on two
|
||||
// channels so a prefix-store directory (no object of its own) is not
|
||||
@@ -141,8 +208,10 @@ async function writeArchive(
|
||||
|
||||
export async function tarGeneric(
|
||||
paths: PathSpec[],
|
||||
texts: readonly string[],
|
||||
opts: CommandOpts,
|
||||
deps: TarDeps,
|
||||
relay = false,
|
||||
): Promise<CommandFnResult> {
|
||||
const fl = new FlagView(opts.flags, specOf('tar'))
|
||||
const create = fl.asBool('c')
|
||||
@@ -163,9 +232,11 @@ export async function tarGeneric(
|
||||
const CFlag = CFlags.length > 0 ? (CFlags[CFlags.length - 1] ?? null) : null
|
||||
const stripN = fl.asInt('strip_components') ?? 0
|
||||
const exclude = fl.asStr('exclude') ?? null
|
||||
const mountPrefix = opts.mountPrefix ?? ''
|
||||
const toStdout = fl.asBool('to_stdout')
|
||||
const mountPrefix = relay ? '' : (opts.mountPrefix ?? '')
|
||||
const archivePath = fFlag
|
||||
const destPath = CFlag ?? '/'
|
||||
const destPath = extractDest(CFlag, opts.cwd)
|
||||
const selectors = [...texts]
|
||||
const verboseLines: string[] = []
|
||||
|
||||
if (create) {
|
||||
@@ -203,9 +274,20 @@ export async function tarGeneric(
|
||||
const raw = await materialize(deps.stream(makePathSpec(archivePath, mountPrefix)))
|
||||
const data = await decompress(raw, compression)
|
||||
const entries = await readTar(data)
|
||||
const out: ByteSource = ENC.encode(
|
||||
entries.map((e) => (e.isDir === true ? `${rstripSlash(e.name)}/` : e.name)).join('\n') + '\n',
|
||||
)
|
||||
const names = entries.map((e) => (e.isDir === true ? `${rstripSlash(e.name)}/` : e.name))
|
||||
const { keep, misses } = selectedMembers(names, selectors)
|
||||
const shown = names.filter((_, index) => keep.has(index))
|
||||
const out: ByteSource | null = shown.length > 0 ? ENC.encode(shown.join('\n') + '\n') : null
|
||||
if (misses.length > 0) {
|
||||
const missStderr = stderrOf([...misses, MISS_TRAILER])
|
||||
return [
|
||||
out,
|
||||
new IOResult({
|
||||
exitCode: 2,
|
||||
...(missStderr !== null ? { stderr: missStderr } : {}),
|
||||
}),
|
||||
]
|
||||
}
|
||||
return [out, new IOResult()]
|
||||
}
|
||||
|
||||
@@ -216,33 +298,89 @@ export async function tarGeneric(
|
||||
const raw = await materialize(deps.stream(makePathSpec(archivePath, mountPrefix)))
|
||||
const data = await decompress(raw, compression)
|
||||
const writes: Record<string, Uint8Array> = {}
|
||||
for (const entry of await readTar(data)) {
|
||||
const entries = await readTar(data)
|
||||
const listed = entries.map((e) => (e.isDir === true ? `${rstripSlash(e.name)}/` : e.name))
|
||||
const { keep, misses } = selectedMembers(listed, selectors)
|
||||
const notices: string[] = []
|
||||
const made = new Set<string>()
|
||||
const chunks: Uint8Array[] = []
|
||||
const toSpec = (virtual: string): PathSpec => makePathSpec(virtual, mountPrefix)
|
||||
for (const [index, entry] of entries.entries()) {
|
||||
if (!keep.has(index)) continue
|
||||
// A symlink member has no bytes to write and no namespace to write
|
||||
// into from here (links are workspace state, not the backend's),
|
||||
// so extraction skips it rather than dropping an empty file where
|
||||
// a link belongs.
|
||||
const isDir = entry.isDir === true
|
||||
if (!entry.isFile && !isDir) continue
|
||||
const nameParts = rstripSlash(entry.name).split('/')
|
||||
const stripped = stripN > 0 ? nameParts.slice(stripN) : nameParts
|
||||
if (stripped.length === 0 || (stripped.length === 1 && stripped[0] === '')) continue
|
||||
const outPath = `${rstripSlash(destPath)}/${stripped.join('/')}`
|
||||
if (isDir) {
|
||||
// A directory member is the only record an empty directory
|
||||
// leaves, so it has to be recreated even though nothing is
|
||||
// written inside it.
|
||||
await deps.mkdir(makePathSpec(outPath, mountPrefix), true)
|
||||
if (verbose) verboseLines.push(`${rstripSlash(entry.name)}/`)
|
||||
if (!toStdout) {
|
||||
// A directory member is the only record an empty directory
|
||||
// leaves, so it has to be recreated even though nothing is
|
||||
// written inside it. Under -O nothing reaches the
|
||||
// filesystem at all.
|
||||
const parts = outParts(entry.name, stripN, notices)
|
||||
if (parts.length > 0) {
|
||||
const outDir = `${rstripSlash(destPath)}/${parts.join('/')}`
|
||||
await ensureDir(outDir, toSpec, deps.mkdir, deps.stat, made)
|
||||
if (verbose) verboseLines.push(`${rstripSlash(entry.name)}/`)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (toStdout) {
|
||||
chunks.push(entry.data)
|
||||
if (verbose) verboseLines.push(entry.name)
|
||||
continue
|
||||
}
|
||||
const parts = outParts(entry.name, stripN, notices)
|
||||
if (parts.length === 0) continue
|
||||
const outPath = `${rstripSlash(destPath)}/${parts.join('/')}`
|
||||
const parent = outPath.slice(0, outPath.lastIndexOf('/')) || '/'
|
||||
if (parent !== '/') await deps.mkdir(makePathSpec(parent, mountPrefix), true)
|
||||
if (parent !== '/') await ensureDir(parent, toSpec, deps.mkdir, deps.stat, made)
|
||||
await deps.write(makePathSpec(outPath, mountPrefix), entry.data)
|
||||
writes[outPath] = entry.data
|
||||
// Relay writes land on whichever mount owns each path and
|
||||
// invalidate through the dispatcher; keying them here would have
|
||||
// the runner prefix them onto this mount.
|
||||
if (!relay) writes[outPath] = entry.data
|
||||
if (verbose) verboseLines.push(entry.name)
|
||||
}
|
||||
const stdout = verbose ? ENC.encode(verboseLines.join('\n') + '\n') : null
|
||||
return [stdout, new IOResult({ writes })]
|
||||
if (toStdout) {
|
||||
// GNU moves the verbose listing to stderr when stdout carries the
|
||||
// member bytes.
|
||||
const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0)
|
||||
const merged = new Uint8Array(total)
|
||||
let offset = 0
|
||||
for (const chunk of chunks) {
|
||||
merged.set(chunk, offset)
|
||||
offset += chunk.byteLength
|
||||
}
|
||||
const errLines = [
|
||||
...notices,
|
||||
...(verbose ? verboseLines : []),
|
||||
...(misses.length > 0 ? [...misses, MISS_TRAILER] : []),
|
||||
]
|
||||
const stderr = stderrOf(errLines)
|
||||
return [
|
||||
merged.byteLength > 0 ? merged : null,
|
||||
new IOResult({
|
||||
exitCode: misses.length > 0 ? 2 : 0,
|
||||
...(stderr !== null ? { stderr } : {}),
|
||||
}),
|
||||
]
|
||||
}
|
||||
const stdout =
|
||||
verbose && verboseLines.length > 0 ? ENC.encode(verboseLines.join('\n') + '\n') : null
|
||||
const errLines = [...notices, ...(misses.length > 0 ? [...misses, MISS_TRAILER] : [])]
|
||||
const stderr = stderrOf(errLines)
|
||||
return [
|
||||
stdout,
|
||||
new IOResult({
|
||||
writes,
|
||||
exitCode: misses.length > 0 ? 2 : 0,
|
||||
...(stderr !== null ? { stderr } : {}),
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
/**
|
||||
* Whether a tar line reads the filesystem rather than an archive.
|
||||
*
|
||||
* Only -c makes tar's operands source paths. Under -t and -x they are
|
||||
* member selectors matched against names inside the archive, so a
|
||||
* selector that happens to spell a mount root is not a mount at all and
|
||||
* refusing it would deny an ordinary listing.
|
||||
*
|
||||
* Scanned raw because both callers fire before flag parsing: the
|
||||
* mount-root policy at admission, and the cross-mount router deciding
|
||||
* whether a span is real. Only the first word may be GNU's dashless
|
||||
* option cluster (`tar cf a.tar d`), so a later bare word is an operand
|
||||
* and cannot turn the mode on.
|
||||
*/
|
||||
export function isCreateMode(argv: readonly string[]): boolean {
|
||||
for (const [i, tok] of argv.entries()) {
|
||||
if (tok === '--create') return true
|
||||
if (tok.startsWith('--')) continue
|
||||
if (tok.startsWith('-')) {
|
||||
if (tok.slice(1).includes('c')) return true
|
||||
continue
|
||||
}
|
||||
if (i === 0 && tok.includes('c')) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
import { specOf } from '../../spec/builtins.ts'
|
||||
import { FlagView } from '../../spec/types.ts'
|
||||
import { mountPrefixOf } from '../../../utils/key_prefix.ts'
|
||||
import { ensureDir, extractDest, type StatDoor } from './archive/extract.ts'
|
||||
import { IOResult, materialize, type ByteSource } from '../../../io/types.ts'
|
||||
import { PathSpec } from '../../../types.ts'
|
||||
import { inflateRaw } from '../../../utils/compress.ts'
|
||||
@@ -179,13 +180,18 @@ export async function unzipGeneric(
|
||||
stream: (p: PathSpec) => AsyncIterable<Uint8Array>,
|
||||
write: (p: PathSpec, data: Uint8Array) => Promise<void>,
|
||||
mkdir: (p: PathSpec, parents?: boolean) => Promise<void>,
|
||||
stat?: StatDoor,
|
||||
relay = false,
|
||||
): Promise<CommandFnResult> {
|
||||
const fl = new FlagView(opts.flags, specOf('unzip'))
|
||||
if (paths.length === 0) {
|
||||
return [null, new IOResult({ exitCode: 1, stderr: ENC.encode('unzip: missing operand\n') })]
|
||||
}
|
||||
const archivePath = paths[0]
|
||||
let archivePath = paths[0]
|
||||
if (archivePath === undefined) return [null, new IOResult()]
|
||||
// Relay doors address by full virtual path (flatten's convention),
|
||||
// not by the mount-relative key the wrapper's accessor stamped.
|
||||
if (relay) archivePath = makePathSpec(archivePath.virtual)
|
||||
const data = await materialize(stream(archivePath))
|
||||
const entries = await readZipEntries(data)
|
||||
const { selected, unmatched } = selectEntries(entries, members)
|
||||
@@ -194,8 +200,8 @@ export async function unzipGeneric(
|
||||
const testMode = fl.asBool('t')
|
||||
const pipeMode = fl.asBool('p')
|
||||
const quiet = fl.asBool('q')
|
||||
const mountPrefix = mountPrefixOf(archivePath.virtual, archivePath.resourcePath)
|
||||
const destRaw = fl.asStr('d') ?? '/'
|
||||
const mountPrefix = relay ? '' : mountPrefixOf(archivePath.virtual, archivePath.resourcePath)
|
||||
const destRaw = extractDest(fl.asStr('d') ?? null, opts.cwd)
|
||||
const dest =
|
||||
mountPrefix !== '' && destRaw.startsWith(mountPrefix + '/')
|
||||
? destRaw.slice(mountPrefix.length)
|
||||
@@ -252,6 +258,7 @@ export async function unzipGeneric(
|
||||
|
||||
const writes: Record<string, Uint8Array> = {}
|
||||
const outputLines: string[] = []
|
||||
const made = new Set<string>()
|
||||
for (const e of selected) {
|
||||
const entryName = lstripSlash(e.name)
|
||||
const outPath = rstripSlash(dest) + '/' + rstripSlash(entryName)
|
||||
@@ -260,13 +267,28 @@ export async function unzipGeneric(
|
||||
// A directory entry is the only record an empty directory leaves,
|
||||
// so it has to be recreated even though nothing is written inside
|
||||
// it.
|
||||
await mkdir(makePathSpec(outPath), true)
|
||||
if (stat !== undefined) {
|
||||
await ensureDir(outPath, makePathSpec, mkdir, stat, made)
|
||||
} else {
|
||||
await mkdir(makePathSpec(outPath), true)
|
||||
}
|
||||
if (!quiet) outputLines.push(` creating: ${reportPath}/`)
|
||||
continue
|
||||
}
|
||||
await ensureParents(mkdir, outPath)
|
||||
const parentEnd = outPath.lastIndexOf('/')
|
||||
const parent = parentEnd > 0 ? outPath.slice(0, parentEnd) : ''
|
||||
if (parent !== '' && parent !== '/') {
|
||||
if (stat !== undefined) {
|
||||
await ensureDir(parent, makePathSpec, mkdir, stat, made)
|
||||
} else {
|
||||
await ensureParents(mkdir, outPath)
|
||||
}
|
||||
}
|
||||
await write(makePathSpec(outPath), e.data)
|
||||
writes[outPath] = e.data
|
||||
// Relay writes land on whichever mount owns each path and
|
||||
// invalidate through the dispatcher; keying them here would have
|
||||
// the runner prefix them onto this mount.
|
||||
if (!relay) writes[outPath] = e.data
|
||||
if (!quiet) outputLines.push(` inflating: ${reportPath}`)
|
||||
}
|
||||
const stdout: ByteSource | null =
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import { type FileStat, FileType, type PathSpec } from '../../../../types.ts'
|
||||
import { specOf } from '../../../spec/builtins.ts'
|
||||
import { FlagView } from '../../../spec/types.ts'
|
||||
import { readBytesOp, statOp } from '../../generic/crossmount/utils.ts'
|
||||
import { tarGeneric } from '../../generic/tar.ts'
|
||||
import { type Builder, resolveGlobOf } from '../adapter.ts'
|
||||
import { walkOf } from '../archive_io.ts'
|
||||
@@ -21,7 +24,7 @@ export const TAR_BUILDER: Builder = {
|
||||
name: 'tar',
|
||||
write: true,
|
||||
requirements: ['write', 'mkdir'],
|
||||
fn: async (ops, accessor, paths, _texts, opts) => {
|
||||
fn: async (ops, accessor, paths, texts, opts) => {
|
||||
const idx = opts.index ?? undefined
|
||||
const { write, mkdir } = ops
|
||||
if (write === undefined || mkdir === undefined) {
|
||||
@@ -29,7 +32,37 @@ export const TAR_BUILDER: Builder = {
|
||||
}
|
||||
const resolved = paths.length > 0 ? await resolveGlobOf(ops)(accessor, paths, idx) : []
|
||||
const stat = async (p: PathSpec): Promise<FileStat> => ops.stat(accessor, p, idx)
|
||||
return tarGeneric(resolved, opts, {
|
||||
const dispatch = opts.dispatch
|
||||
const fl = new FlagView(opts.flags, specOf('tar'))
|
||||
if (dispatch !== undefined && !fl.asBool('c')) {
|
||||
// -t reads and -x writes wherever cwd or -C says, which need not
|
||||
// be this mount, so both run on dispatch-relayed doors and each
|
||||
// path routes to the mount that owns it. Only -c stays on the
|
||||
// accessor: its planner walks this mount's tree.
|
||||
const readBytes = readBytesOp(dispatch)
|
||||
async function* streamOf(p: PathSpec): AsyncIterable<Uint8Array> {
|
||||
yield await readBytes(p)
|
||||
}
|
||||
return tarGeneric(
|
||||
resolved,
|
||||
texts,
|
||||
opts,
|
||||
{
|
||||
stream: streamOf,
|
||||
write: async (p, data) => {
|
||||
await dispatch('write', p, [data])
|
||||
},
|
||||
mkdir: async (p) => {
|
||||
await dispatch('mkdir', p)
|
||||
},
|
||||
stat: statOp(dispatch),
|
||||
walk: walkOf(ops, accessor, idx),
|
||||
isDir: () => Promise.resolve(false),
|
||||
},
|
||||
true,
|
||||
)
|
||||
}
|
||||
return tarGeneric(resolved, texts, opts, {
|
||||
stream: (p) => ops.readStream(accessor, p, idx),
|
||||
write: (p, data) => write(accessor, p, data),
|
||||
mkdir: (p, parents) => mkdir(accessor, p, parents),
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import type { PathSpec } from '../../../../types.ts'
|
||||
import { readBytesOp, statOp } from '../../generic/crossmount/utils.ts'
|
||||
import { unzipGeneric } from '../../generic/unzip.ts'
|
||||
import { type Builder, resolveGlobOf } from '../adapter.ts'
|
||||
|
||||
@@ -26,6 +28,30 @@ export const UNZIP_BUILDER: Builder = {
|
||||
throw new Error('unzip: backend provides no write op')
|
||||
}
|
||||
const resolved = paths.length > 0 ? await resolveGlobOf(ops)(accessor, paths, idx) : []
|
||||
const dispatch = opts.dispatch
|
||||
if (dispatch !== undefined) {
|
||||
// Extraction writes wherever cwd or -d says, which need not be
|
||||
// this mount, so the doors are dispatch-relayed and each path
|
||||
// routes to the mount that owns it.
|
||||
const readBytes = readBytesOp(dispatch)
|
||||
async function* streamOf(p: PathSpec): AsyncIterable<Uint8Array> {
|
||||
yield await readBytes(p)
|
||||
}
|
||||
return unzipGeneric(
|
||||
resolved,
|
||||
texts,
|
||||
opts,
|
||||
streamOf,
|
||||
async (p, data) => {
|
||||
await dispatch('write', p, [data])
|
||||
},
|
||||
async (p) => {
|
||||
await dispatch('mkdir', p)
|
||||
},
|
||||
statOp(dispatch),
|
||||
true,
|
||||
)
|
||||
}
|
||||
return unzipGeneric(
|
||||
resolved,
|
||||
texts,
|
||||
|
||||
@@ -18,6 +18,7 @@ import { mountKey, mountPrefixOf } from '../../utils/key_prefix.ts'
|
||||
import { AsyncLineIterator } from '../../io/async_line_iterator.ts'
|
||||
import { materialize, type IOResult } from '../../io/types.ts'
|
||||
import { type FileStat, FileType, PathSpec } from '../../types.ts'
|
||||
import { fnmatch } from '../../utils/fnmatch.ts'
|
||||
import { getExtension } from '../resolve.ts'
|
||||
import { PatternType } from './constants.ts'
|
||||
import { grepContextLines } from './grep_context.ts'
|
||||
@@ -36,6 +37,48 @@ export const BINARY_EXTENSIONS: ReadonlySet<string> = new Set([
|
||||
|
||||
export const NEVER_MATCH = '(?!)'
|
||||
|
||||
/**
|
||||
* GNU's file-selection flags, threaded as one value.
|
||||
*
|
||||
* `include`/`exclude` gate files, `excludeDir` prunes directories from
|
||||
* the -r walk, and `text` (-a) lets the walk read the extensions it
|
||||
* would otherwise skip as binary. The empty value admits everything,
|
||||
* which is what every caller without the flags passes.
|
||||
*/
|
||||
export interface WalkFilters {
|
||||
include: readonly string[]
|
||||
exclude: readonly string[]
|
||||
excludeDir: readonly string[]
|
||||
text: boolean
|
||||
}
|
||||
|
||||
export const NO_FILTERS: WalkFilters = { include: [], exclude: [], excludeDir: [], text: false }
|
||||
|
||||
/**
|
||||
* GNU's --include/--exclude gate for one candidate file.
|
||||
*
|
||||
* Globs match the base name with fnmatch wildcards, case sensitively (a
|
||||
* glob carrying a slash therefore matches nothing, which is what GNU
|
||||
* 3.11 answers too), and --exclude wins when both match. Applies to
|
||||
* command-line files exactly as to walked ones, which is pinned GNU
|
||||
* behavior: an explicit operand --include passes over is silently no
|
||||
* match, not an error.
|
||||
*/
|
||||
export function fileAdmitted(path: string, filters: WalkFilters): boolean {
|
||||
const trimmed = path.endsWith('/') ? path.slice(0, -1) : path
|
||||
const base = trimmed.slice(trimmed.lastIndexOf('/') + 1)
|
||||
if (filters.exclude.some((glob) => fnmatch(base, glob))) return false
|
||||
if (filters.include.length > 0) return filters.include.some((glob) => fnmatch(base, glob))
|
||||
return true
|
||||
}
|
||||
|
||||
/** Whether the -r walk may descend into this directory. */
|
||||
export function dirAdmitted(path: string, filters: WalkFilters): boolean {
|
||||
const trimmed = path.endsWith('/') ? path.slice(0, -1) : path
|
||||
const base = trimmed.slice(trimmed.lastIndexOf('/') + 1)
|
||||
return !filters.excludeDir.some((glob) => fnmatch(base, glob))
|
||||
}
|
||||
|
||||
const DEC = new TextDecoder()
|
||||
|
||||
function escapeRegex(s: string): string {
|
||||
@@ -267,7 +310,11 @@ export function hasSearchShapingFlags(flags: Record<string, FlagValue>): boolean
|
||||
typeof flags.B === 'string' ||
|
||||
typeof flags.C === 'string' ||
|
||||
flags.type !== undefined ||
|
||||
flags.glob !== undefined
|
||||
flags.glob !== undefined ||
|
||||
flags.text === true ||
|
||||
flags.include !== undefined ||
|
||||
flags.exclude !== undefined ||
|
||||
flags.exclude_dir !== undefined
|
||||
)
|
||||
}
|
||||
|
||||
@@ -450,6 +497,7 @@ export interface GrepFilesOnlyOptions {
|
||||
maxCount: number | null
|
||||
wholeWord: boolean
|
||||
basic: boolean
|
||||
filters?: WalkFilters
|
||||
}
|
||||
|
||||
export async function grepRecursive(
|
||||
@@ -488,7 +536,9 @@ export async function grepRecursive(
|
||||
warnings.push(`grep: ${entry}: ${err instanceof Error ? err.message : String(err)}`)
|
||||
continue
|
||||
}
|
||||
const filters = opts.filters ?? NO_FILTERS
|
||||
if (s.type === FileType.DIRECTORY) {
|
||||
if (!dirAdmitted(entry, filters)) continue
|
||||
const sub = await grepRecursive(
|
||||
readdirFn,
|
||||
statFn,
|
||||
@@ -502,7 +552,8 @@ export async function grepRecursive(
|
||||
for (const r of sub) results.push(r)
|
||||
continue
|
||||
}
|
||||
if (BINARY_EXTENSIONS.has(getExtension(entry) ?? '')) continue
|
||||
if (!fileAdmitted(entry, filters)) continue
|
||||
if (!filters.text && BINARY_EXTENSIONS.has(getExtension(entry) ?? '')) continue
|
||||
try {
|
||||
const lines = new TextDecoder('utf-8', { fatal: false })
|
||||
.decode(await readBytesFn(entry))
|
||||
@@ -608,6 +659,7 @@ export async function grepFilesOnly(
|
||||
if (warnings !== null) warnings.push(`grep: ${path}: Is a directory`)
|
||||
return []
|
||||
}
|
||||
if (!fileAdmitted(path, opts.filters ?? NO_FILTERS)) return []
|
||||
let data: Uint8Array
|
||||
try {
|
||||
data = await readBytesFn(path)
|
||||
|
||||
@@ -53,6 +53,7 @@ export const SPECS: Record<string, CommandSpec> = {
|
||||
new Option({ short: '-v' }),
|
||||
// -h archives what a symlink points at instead of the link.
|
||||
new Option({ short: '-h' }),
|
||||
new Option({ short: '-O', long: '--to-stdout' }),
|
||||
new Option({ short: '-f', type: 'path' }),
|
||||
// Every occurrence is kept, in order: GNU chdirs at each one and
|
||||
// fails at the first it cannot enter, so the planner has to see
|
||||
@@ -61,7 +62,12 @@ export const SPECS: Record<string, CommandSpec> = {
|
||||
new Option({ long: '--strip-components', type: 'str' }),
|
||||
new Option({ long: '--exclude', type: 'str' }),
|
||||
],
|
||||
rest: new Operand({ type: 'path' }),
|
||||
// Only -c reads the rest operands from the filesystem. Under -t
|
||||
// and -x each one is a member selector matched against names
|
||||
// inside the archive (the same mode split MountRootPolicy keys
|
||||
// on), so those lines keep them textual, exactly as unzip's
|
||||
// member patterns are.
|
||||
rest: new Operand({ type: 'path', textWhen: ['-x', '-t'] }),
|
||||
// `tar xzf a.tgz` is the spelling everyone types.
|
||||
oldOptionStyle: true,
|
||||
// -C is a chdir for the operands after it, not a flag the command
|
||||
|
||||
@@ -50,6 +50,13 @@ export const SPECS: Record<string, CommandSpec> = {
|
||||
new Option({ short: '-C', type: 'str' }),
|
||||
new Option({ short: '-e', type: 'str', multiple: true }),
|
||||
new Option({ short: '-f', type: 'path', multiple: true }),
|
||||
// -a searches the extensions the -r walk skips as binary;
|
||||
// explicit operands are always read as text, which is the
|
||||
// documented divergence (no "binary file matches" rows).
|
||||
new Option({ short: '-a', long: '--text' }),
|
||||
new Option({ long: '--include', type: 'str', multiple: true }),
|
||||
new Option({ long: '--exclude', type: 'str', multiple: true }),
|
||||
new Option({ long: '--exclude-dir', type: 'str', multiple: true }),
|
||||
// Accepted no-ops: output is never a tty, so plain output is
|
||||
// exactly what GNU produces with --color=auto (#471).
|
||||
new Option({ long: '--color', type: 'str', valueOptional: true }),
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
import type { Policy } from '../base.ts'
|
||||
import type { Action, CommandContext, Deny } from '../types.ts'
|
||||
import { isCreateMode } from '../../commands/builtin/generic/tar/mode.ts'
|
||||
import type { PathSpec } from '../../types.ts'
|
||||
|
||||
/**
|
||||
@@ -45,32 +46,6 @@ export function hasParentsFlag(argv: readonly string[]): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a tar line reads the filesystem rather than an archive.
|
||||
*
|
||||
* Only `-c` makes tar's operands source paths. Under `-t` and `-x` they are
|
||||
* member selectors matched against names inside the archive, so a selector
|
||||
* that happens to spell a mount root is not a mount at all and refusing it
|
||||
* would deny an ordinary listing.
|
||||
*
|
||||
* Scanned raw for the same reason hasSymlinkFlag is: the policy fires before
|
||||
* flag parsing. Only the first word may be GNU's dashless option cluster
|
||||
* (`tar cf a.tar d`), so a later bare word is an operand and cannot turn the
|
||||
* mode on.
|
||||
*/
|
||||
function isCreateMode(argv: readonly string[]): boolean {
|
||||
for (const [i, tok] of argv.entries()) {
|
||||
if (tok === '--create') return true
|
||||
if (tok.startsWith('--')) continue
|
||||
if (tok.startsWith('-')) {
|
||||
if (tok.slice(1).includes('c')) return true
|
||||
continue
|
||||
}
|
||||
if (i === 0 && tok.includes('c')) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function deny(message: string, exitCode = 1): Deny {
|
||||
return { kind: 'deny', message, exitCode }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { writeTar } from '../../commands/builtin/tar_helper.ts'
|
||||
import { OpsRegistry } from '../../ops/registry.ts'
|
||||
import { RAMResource } from '../../resource/ram/ram.ts'
|
||||
import { MountMode } from '../../types.ts'
|
||||
import { gzip } from '../../utils/compress.ts'
|
||||
import { getTestParser, stderrStr, stdoutStr } from '../fixtures/workspace_fixture.ts'
|
||||
import { Workspace } from '../workspace.ts'
|
||||
|
||||
// Direct port of tests/workspace/executor/test_archive_relay.py: member
|
||||
// selectors stay off routing, extraction lands in the cwd or -C across
|
||||
// mounts through relay doors, and a create-mode span keeps the refusal.
|
||||
|
||||
const ENC = new TextEncoder()
|
||||
|
||||
async function tgzBytes(): Promise<Uint8Array> {
|
||||
const raw = await writeTar([
|
||||
{
|
||||
name: './memory/memory.json',
|
||||
data: ENC.encode('content:./memory/memory.json\n'),
|
||||
isFile: true,
|
||||
isDir: false,
|
||||
linkname: '',
|
||||
},
|
||||
{
|
||||
name: './other.txt',
|
||||
data: ENC.encode('content:./other.txt\n'),
|
||||
isFile: true,
|
||||
isDir: false,
|
||||
linkname: '',
|
||||
},
|
||||
])
|
||||
return gzip(raw)
|
||||
}
|
||||
|
||||
async function makeWs(): Promise<Workspace> {
|
||||
const parser = await getTestParser()
|
||||
const root = new RAMResource()
|
||||
const work = new RAMResource()
|
||||
work.store.files.set('/files.tar.gz', await tgzBytes())
|
||||
const registry = new OpsRegistry()
|
||||
registry.registerResource(root)
|
||||
registry.registerResource(work)
|
||||
return new Workspace(
|
||||
{ '/': root, '/work/': work },
|
||||
{ mode: MountMode.WRITE, ops: registry, shellParser: parser },
|
||||
)
|
||||
}
|
||||
|
||||
describe('tar member selectors and relay extraction', () => {
|
||||
it('a selector does not join routing', async () => {
|
||||
const ws = await makeWs()
|
||||
const io = await ws.execute('tar -xOzf /work/files.tar.gz ./memory/memory.json')
|
||||
expect(stderrStr(io)).toBe('')
|
||||
expect(io.exitCode).toBe(0)
|
||||
expect(stdoutStr(io)).toBe('content:./memory/memory.json\n')
|
||||
})
|
||||
|
||||
it('a -t selector lists only its subtree', async () => {
|
||||
const ws = await makeWs()
|
||||
const io = await ws.execute('tar -tzf /work/files.tar.gz ./memory')
|
||||
expect(io.exitCode).toBe(0)
|
||||
expect(stdoutStr(io)).toBe('./memory/memory.json\n')
|
||||
})
|
||||
|
||||
it('a miss reports Not found in archive and exits 2', async () => {
|
||||
const ws = await makeWs()
|
||||
const io = await ws.execute('tar -tzf /work/files.tar.gz nope')
|
||||
expect(io.exitCode).toBe(2)
|
||||
expect(stderrStr(io)).toBe(
|
||||
'tar: nope: Not found in archive\n' +
|
||||
'tar: Exiting with failure status due to previous errors\n',
|
||||
)
|
||||
})
|
||||
|
||||
it('extraction lands in the cwd across mounts', async () => {
|
||||
const ws = await makeWs()
|
||||
const io = await ws.execute('tar -xzf /work/files.tar.gz')
|
||||
expect(io.exitCode).toBe(0)
|
||||
const cat = await ws.execute('cat /memory/memory.json')
|
||||
expect(stdoutStr(cat)).toBe('content:./memory/memory.json\n')
|
||||
})
|
||||
|
||||
it('-C extracts into another mount', async () => {
|
||||
const ws = await makeWs()
|
||||
const io = await ws.execute('tar -xzf /work/files.tar.gz -C /dest')
|
||||
expect(stderrStr(io)).toBe('')
|
||||
expect(io.exitCode).toBe(0)
|
||||
const cat = await ws.execute('cat /dest/other.txt')
|
||||
expect(stdoutStr(cat)).toBe('content:./other.txt\n')
|
||||
})
|
||||
|
||||
it('a create-mode span keeps the refusal', async () => {
|
||||
const ws = await makeWs()
|
||||
await ws.execute('mkdir -p /src && echo hi > /src/f.txt')
|
||||
const io = await ws.execute('tar -czf /work/backup.tgz /src')
|
||||
expect(io.exitCode).toBe(1)
|
||||
expect(stderrStr(io)).toContain('paths span multiple mounts')
|
||||
})
|
||||
})
|
||||
@@ -43,7 +43,7 @@ export {
|
||||
} from './vars.ts'
|
||||
export { handleMan } from './man.ts'
|
||||
export { handleHistory } from './history.ts'
|
||||
export { handleBash, handleEval, handleSleep, handleSource } from './script.ts'
|
||||
export { handleBash, handleEval, handleExecPath, handleSleep, handleSource } from './script.ts'
|
||||
export { handleTest } from './condition/index.ts'
|
||||
export { handleTimeout } from './timeout.ts'
|
||||
export { handleXargs } from './xargs.ts'
|
||||
|
||||
@@ -233,6 +233,85 @@ async function readScriptFile(
|
||||
}
|
||||
}
|
||||
|
||||
/** The interpreter words a script's first line names, env resolved. */
|
||||
function shebangWords(script: string): string[] {
|
||||
const first = script.split('\n', 1)[0] ?? ''
|
||||
if (!first.startsWith('#!')) return []
|
||||
let words = first
|
||||
.slice(2)
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter((w) => w !== '')
|
||||
const head = words[0] ?? ''
|
||||
if (head.slice(head.lastIndexOf('/') + 1) === 'env') {
|
||||
words = words.slice(1)
|
||||
return words
|
||||
}
|
||||
if (words.length > 0) {
|
||||
words[0] = head.slice(head.lastIndexOf('/') + 1)
|
||||
}
|
||||
return words
|
||||
}
|
||||
|
||||
/** Quote one word for re-dispatch as a shell line. */
|
||||
function quoteWord(word: string): string {
|
||||
if (/^[A-Za-z0-9_\-./]+$/.test(word)) return word
|
||||
return `'${word.replaceAll("'", `'\\''`)}'`
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a slash-carrying head word as a program, bash's loader rule.
|
||||
*
|
||||
* bash hands a word containing a slash straight to the loader: no
|
||||
* builtin, function, or install can claim it, and the file either runs
|
||||
* or the shell reports why not. Two deliberate divergences from bash,
|
||||
* both consequences of the VFS: there is no exec bit to check (`chmod`
|
||||
* is stored, not enforced; mount mode does real access control), so an
|
||||
* existing file runs without `+x`; and the shell prefix bash puts on
|
||||
* the diagnostic is dropped, matching every other mirage diagnostic.
|
||||
*
|
||||
* A shebang naming sh or bash (directly or via env) runs through the
|
||||
* nested-shell machinery, as does a script with none. Any other
|
||||
* interpreter word is re-dispatched as a command line, so
|
||||
* `#!/usr/bin/env python3` reaches the python3 command wherever the
|
||||
* workspace routes it, and an interpreter nobody registers answers with
|
||||
* its own "command not found".
|
||||
*/
|
||||
export async function handleExecPath(
|
||||
dispatch: DispatchFn,
|
||||
executeFn: ExecuteStringFn,
|
||||
path: string,
|
||||
args: string[],
|
||||
session: Session,
|
||||
stdin: ByteSource | null = null,
|
||||
): Promise<Result> {
|
||||
let script: string
|
||||
try {
|
||||
script = await readScriptText(dispatch, path, session.cwd)
|
||||
} catch (exc) {
|
||||
const strerror = fsStrerror(exc)
|
||||
if (strerror === null) throw exc
|
||||
const code = (exc as { code?: string }).code
|
||||
return scriptError(path, strerror, code === 'ENOENT' ? 127 : 126)
|
||||
}
|
||||
const words = shebangWords(script)
|
||||
const interp = words[0] ?? 'sh'
|
||||
if (interp === 'sh' || interp === 'bash') {
|
||||
return handleBash(
|
||||
dispatch,
|
||||
executeFn,
|
||||
[...words.slice(1), path, ...args],
|
||||
session,
|
||||
stdin,
|
||||
interp,
|
||||
)
|
||||
}
|
||||
const line = [...words, path, ...args].map(quoteWord).join(' ')
|
||||
const io = await executeFn(line, { sessionId: session.sessionId, stdin })
|
||||
const label = args.length > 0 ? `${path} ${args.join(' ')}` : path
|
||||
return [io.stdout, io, new ExecutionNode({ command: label, exitCode: io.exitCode })]
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a nested shell: inline text from `-c`, or a script file.
|
||||
*
|
||||
|
||||
@@ -32,6 +32,7 @@ import type { Session } from '../session/session.ts'
|
||||
import { ExecutionNode } from '../types.ts'
|
||||
import { strategyFor } from '../../commands/builtin/generic/crossmount/detect.ts'
|
||||
import type { Cmd } from '../../commands/builtin/generic/crossmount/types.ts'
|
||||
import { isCreateMode } from '../../commands/builtin/generic/tar/mode.ts'
|
||||
import { Strategy } from '../../commands/builtin/generic/crossmount/types.ts'
|
||||
import { resolveGlobs } from '../expand/globs.ts'
|
||||
import type { DispatchFn } from '../../runtime/types.ts'
|
||||
@@ -217,8 +218,14 @@ export async function handleCommand(
|
||||
}
|
||||
|
||||
// Path-valued flags count: `cp -t /other/mount/dir src` spans mounts
|
||||
// exactly like a positional destination would.
|
||||
if (isCrossMount(cmdName, routingScopes, registry)) {
|
||||
// exactly like a positional destination would. A create-mode tar is
|
||||
// the one relay member kept out: its planner walks a single backend's
|
||||
// tree, so a span there falls through to the refusal below instead of
|
||||
// a relay run that would cross nested mounts.
|
||||
if (
|
||||
isCrossMount(cmdName, routingScopes, registry) &&
|
||||
!(cmdName === 'tar' && isCreateMode(rawArgv))
|
||||
) {
|
||||
// Parse against the shared spec so flags and text operands do not
|
||||
// depend on the source mount: raw argv would hand flag tokens ("-c")
|
||||
// to the generic as the search pattern. The bound single-mount runner
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { OpsRegistry } from '../../ops/registry.ts'
|
||||
import { RAMResource } from '../../resource/ram/ram.ts'
|
||||
import { MountMode } from '../../types.ts'
|
||||
import { getTestParser, stderrStr, stdoutStr } from '../fixtures/workspace_fixture.ts'
|
||||
import { Workspace } from '../workspace.ts'
|
||||
|
||||
// Direct port of tests/workspace/executor/builtins/test_exec_path.py: a
|
||||
// slash-carrying head word runs the file, bash's loader rule.
|
||||
|
||||
async function makeWs(): Promise<Workspace> {
|
||||
const parser = await getTestParser()
|
||||
const root = new RAMResource()
|
||||
const work = new RAMResource()
|
||||
const registry = new OpsRegistry()
|
||||
registry.registerResource(root)
|
||||
registry.registerResource(work)
|
||||
return new Workspace(
|
||||
{ '/': root, '/work/': work },
|
||||
{ mode: MountMode.WRITE, ops: registry, shellParser: parser },
|
||||
)
|
||||
}
|
||||
|
||||
describe('direct path execution', () => {
|
||||
it('runs a script written to a mount', async () => {
|
||||
const ws = await makeWs()
|
||||
await ws.execute("printf 'echo ran\\n' > /work/run.sh")
|
||||
const io = await ws.execute('/work/run.sh')
|
||||
expect(io.exitCode).toBe(0)
|
||||
expect(stdoutStr(io)).toBe('ran\n')
|
||||
})
|
||||
|
||||
it('resolves a relative path against the cwd', async () => {
|
||||
const ws = await makeWs()
|
||||
await ws.execute("printf 'echo rel\\n' > /work/run.sh")
|
||||
const io = await ws.execute('cd /work && ./run.sh')
|
||||
expect(stdoutStr(io)).toBe('rel\n')
|
||||
})
|
||||
|
||||
it('honors shebang interpreter options', async () => {
|
||||
const ws = await makeWs()
|
||||
await ws.execute("printf '#!/bin/bash -x\\necho traced\\n' > /work/t.sh")
|
||||
const io = await ws.execute('/work/t.sh')
|
||||
expect(stdoutStr(io)).toBe('traced\n')
|
||||
expect(stderrStr(io)).toBe('+ echo traced\n')
|
||||
})
|
||||
|
||||
it('a missing file is 127', async () => {
|
||||
const ws = await makeWs()
|
||||
const io = await ws.execute('/work/nope.sh')
|
||||
expect(io.exitCode).toBe(127)
|
||||
expect(stderrStr(io)).toBe('/work/nope.sh: No such file or directory\n')
|
||||
})
|
||||
|
||||
it('a directory is 126', async () => {
|
||||
const ws = await makeWs()
|
||||
await ws.execute('mkdir -p /work/adir')
|
||||
const io = await ws.execute('/work/adir')
|
||||
expect(io.exitCode).toBe(126)
|
||||
expect(stderrStr(io)).toBe('/work/adir: Is a directory\n')
|
||||
})
|
||||
|
||||
it('an unknown interpreter reports command not found', async () => {
|
||||
const ws = await makeWs()
|
||||
await ws.execute("printf '#!/usr/bin/env ruby\\nputs 1\\n' > /work/r.rb")
|
||||
const io = await ws.execute('/work/r.rb')
|
||||
expect(io.exitCode).toBe(127)
|
||||
expect(stderrStr(io)).toBe('ruby: command not found\n')
|
||||
})
|
||||
|
||||
it('child shell state does not leak', async () => {
|
||||
const ws = await makeWs()
|
||||
await ws.execute("printf 'cd /work\\nexit 3\\n' > /child.sh")
|
||||
const io = await ws.execute('/child.sh; echo "$?:$(pwd)"')
|
||||
expect(stdoutStr(io)).toBe('3:/\n')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { OpsRegistry } from '../../ops/registry.ts'
|
||||
import { RAMResource } from '../../resource/ram/ram.ts'
|
||||
import { MountMode } from '../../types.ts'
|
||||
import { getTestParser, stderrStr, stdoutStr } from '../fixtures/workspace_fixture.ts'
|
||||
import { Workspace } from '../workspace.ts'
|
||||
|
||||
// Direct port of tests/commands/native/test_grep_filters.py, pinned
|
||||
// against GNU grep 3.11 on debian:stable-slim.
|
||||
|
||||
const ENC = new TextEncoder()
|
||||
|
||||
async function makeWs(): Promise<Workspace> {
|
||||
const parser = await getTestParser()
|
||||
const r = new RAMResource()
|
||||
r.store.files.set('/notes.tex', ENC.encode('score 9\n'))
|
||||
r.store.files.set('/notes.txt', ENC.encode('score 8\n'))
|
||||
r.store.dirs.add('/sub')
|
||||
r.store.files.set('/sub/inner.tex', ENC.encode('score 7\n'))
|
||||
r.store.files.set('/data.parquet', ENC.encode('score binary\n'))
|
||||
const registry = new OpsRegistry()
|
||||
registry.registerResource(r)
|
||||
return new Workspace({ '/': r }, { mode: MountMode.WRITE, ops: registry, shellParser: parser })
|
||||
}
|
||||
|
||||
describe('grep --include/--exclude/--exclude-dir and -a', () => {
|
||||
it('--include filters the recursive walk', async () => {
|
||||
const ws = await makeWs()
|
||||
const io = await ws.execute("grep -RInE --include='*.tex' score /")
|
||||
expect(io.exitCode).toBe(0)
|
||||
expect(stdoutStr(io)).toBe('/notes.tex:1:score 9\n/sub/inner.tex:1:score 7\n')
|
||||
})
|
||||
|
||||
it('--exclude wins over --include', async () => {
|
||||
const ws = await makeWs()
|
||||
const io = await ws.execute("grep -r --include='*.tex' --exclude='notes.*' score /")
|
||||
expect(stdoutStr(io)).toBe('/sub/inner.tex:score 7\n')
|
||||
})
|
||||
|
||||
it('--exclude-dir prunes the walk', async () => {
|
||||
const ws = await makeWs()
|
||||
const io = await ws.execute("grep -r --include='*.tex' --exclude-dir=sub score /")
|
||||
expect(stdoutStr(io)).toBe('/notes.tex:score 9\n')
|
||||
})
|
||||
|
||||
it('a glob carrying a slash matches nothing', async () => {
|
||||
const ws = await makeWs()
|
||||
const io = await ws.execute("grep -r --include='sub/*.tex' score /")
|
||||
expect(io.exitCode).toBe(1)
|
||||
expect(stdoutStr(io)).toBe('')
|
||||
})
|
||||
|
||||
it('--include filters an explicit operand in silence', async () => {
|
||||
const ws = await makeWs()
|
||||
const io = await ws.execute("grep --include='*.tex' -n score /notes.txt")
|
||||
expect(io.exitCode).toBe(1)
|
||||
expect(stdoutStr(io)).toBe('')
|
||||
expect(stderrStr(io)).toBe('')
|
||||
})
|
||||
|
||||
it('-a reads binary extensions in the walk', async () => {
|
||||
const ws = await makeWs()
|
||||
const without = await ws.execute('grep -r score /')
|
||||
expect(stdoutStr(without)).not.toContain('data.parquet')
|
||||
const withA = await ws.execute('grep -ra score /')
|
||||
expect(stdoutStr(withA)).toContain('/data.parquet:score binary')
|
||||
})
|
||||
|
||||
it('-a is accepted on an explicit operand', async () => {
|
||||
const ws = await makeWs()
|
||||
const io = await ws.execute("grep -aoiE 'SCORE' /notes.tex")
|
||||
expect(stdoutStr(io)).toBe('score\n')
|
||||
})
|
||||
})
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
acceptsLine,
|
||||
followPaths,
|
||||
handleBash,
|
||||
handleExecPath,
|
||||
handleCd,
|
||||
handleCommandBuiltin,
|
||||
handleType,
|
||||
@@ -510,6 +511,14 @@ async function runArgv(
|
||||
}
|
||||
}
|
||||
|
||||
// Path execution: bash hands a slash-carrying head word to the
|
||||
// loader, never to command lookup: no builtin, function, or CLI can
|
||||
// claim it. After the admission gate so a policy sees the line like
|
||||
// any other.
|
||||
if (name.includes('/')) {
|
||||
return handleExecPath(dispatch, executeFn, name, args, session, stdin)
|
||||
}
|
||||
|
||||
// Unsupported bash builtins. Constructs the parser accepts but the
|
||||
// executor cannot honor. Returning a clear error lets LLMs detect a
|
||||
// capability gap instead of treating it as a missing binary.
|
||||
|
||||
Reference in New Issue
Block a user