diff --git a/client/src/components/FilesDialog/FilesDialog.vue b/client/src/components/FilesDialog/FilesDialog.vue
index 6c31f8204b8f..4d05e124f50a 100644
--- a/client/src/components/FilesDialog/FilesDialog.vue
+++ b/client/src/components/FilesDialog/FilesDialog.vue
@@ -60,6 +60,10 @@ export default {
default: "file",
validator: (prop) => ["file", "directory"].includes(prop),
},
+ requireWritable: {
+ type: Boolean,
+ default: false,
+ },
},
data() {
return {
@@ -129,16 +133,19 @@ export default {
this.services
.getFileSources()
.then((items) => {
- this.items = items.map((item) => {
- return {
- id: item.id,
- label: item.label,
- details: item.doc,
- isLeaf: false,
- url: item.uri_root,
- labelTitle: item.uri_root,
- };
- });
+ items = items
+ .filter((item) => !this.requireWritable || item.writable)
+ .map((item) => {
+ return {
+ id: item.id,
+ label: item.label,
+ details: item.doc,
+ isLeaf: false,
+ url: item.uri_root,
+ labelTitle: item.uri_root,
+ };
+ });
+ this.items = items;
this.formatRows();
this.optionsShow = true;
this.showTime = false;
diff --git a/client/src/mvc/form/form-parameters.js b/client/src/mvc/form/form-parameters.js
index 92d4079d22a6..189d8856f56e 100644
--- a/client/src/mvc/form/form-parameters.js
+++ b/client/src/mvc/form/form-parameters.js
@@ -10,6 +10,7 @@ import SelectContent from "mvc/ui/ui-select-content";
import SelectLibrary from "mvc/ui/ui-select-library";
import SelectFtp from "mvc/ui/ui-select-ftp";
import RulesEdit from "mvc/ui/ui-rules-edit";
+import FileSource from "mvc/ui/ui-file-source";
import ColorPicker from "mvc/ui/ui-color-picker";
import DataPicker from "mvc/ui/ui-data-picker";
@@ -37,6 +38,7 @@ export default Backbone.Model.extend({
ftpfile: "_fieldFtp",
upload: "_fieldUpload",
rules: "_fieldRulesEdit",
+ directory_uri: "_fieldDirectoryUri",
data_dialog: "_fieldDialog",
},
@@ -238,6 +240,14 @@ export default Backbone.Model.extend({
});
},
+ _fieldDirectoryUri: function (input_def) {
+ return new FileSource.View({
+ id: `field-${input_def.id}`,
+ onchange: input_def.onchange,
+ target: input_def.target,
+ });
+ },
+
_fieldRulesEdit: function (input_def) {
return new RulesEdit.View({
id: `field-${input_def.id}`,
diff --git a/client/src/mvc/ui/ui-file-source.js b/client/src/mvc/ui/ui-file-source.js
new file mode 100644
index 000000000000..879bf20ebd7f
--- /dev/null
+++ b/client/src/mvc/ui/ui-file-source.js
@@ -0,0 +1,100 @@
+import Backbone from "backbone";
+import { filesDialog } from "utils/data";
+import _l from "utils/localization";
+import Ui from "mvc/ui/ui-misc";
+
+var View = Backbone.View.extend({
+ initialize: function (options) {
+ this.model = new Backbone.Model();
+ this.target = options.target;
+ const props = {
+ mode: "directory",
+ requireWritable: true,
+ };
+ // create insert edit button
+ this.browse_button = new Ui.Button({
+ title: _l("Select"),
+ icon: "fa fa-edit",
+ tooltip: _l("Select URI"),
+ onclick: () => {
+ filesDialog((uri) => {
+ this._handleRemoteFilesUri(uri);
+ }, props);
+ },
+ });
+
+ // add change event. fires on trigger
+ this.on("change", () => {
+ if (options.onchange) {
+ options.onchange(this.value());
+ }
+ });
+
+ // create elements
+ this.setElement(this._template(options));
+ this.$text = this.$(".ui-uri-preview");
+ this.$(".ui-file-select-button").append(this.browse_button.$el);
+ this.listenTo(this.model, "change", this.render, this);
+ this.render();
+ },
+
+ _handleRemoteFilesUri: function (uri) {
+ this._setValue(uri);
+ },
+
+ render: function () {
+ const value = this._value;
+ if (value) {
+ if (value.url !== this.$text.text()) {
+ this.$text.text(value.url);
+ }
+ } else {
+ this.$text.text("select...");
+ }
+ },
+
+ /** Main Template */
+ _template: function (options) {
+ return `
+
+
+
+
+ `;
+ },
+
+ /** Return/Set current value */
+ value: function (new_value) {
+ if (new_value !== undefined) {
+ this._setValue(new_value);
+ } else {
+ return this._getValue();
+ }
+ },
+
+ /** Update input element options */
+ update: function (input_def) {
+ this.target = input_def.target;
+ },
+
+ /** Returns current value */
+ _getValue: function () {
+ return this._value.url;
+ },
+
+ /** Sets current value */
+ _setValue: function (new_value) {
+ if (new_value) {
+ if (typeof new_value == "string") {
+ new_value = JSON.parse(new_value);
+ }
+ this._value = new_value;
+ this.model.trigger("error", null);
+ this.model.trigger("change");
+ }
+ },
+});
+
+export default {
+ View: View,
+};
diff --git a/client/src/mvc/ui/ui-rules-edit.js b/client/src/mvc/ui/ui-rules-edit.js
index 566a61047124..ebb09946d454 100644
--- a/client/src/mvc/ui/ui-rules-edit.js
+++ b/client/src/mvc/ui/ui-rules-edit.js
@@ -23,7 +23,7 @@ var View = Backbone.View.extend({
tooltip: _l("Edit Rules"),
onclick: () => {
if (view.target) {
- view._fetcCollectionAndEdit();
+ view._fetchCollectionAndEdit();
} else {
view._showRuleEditor(null);
}
@@ -55,7 +55,7 @@ var View = Backbone.View.extend({
this.collapsible_disabled = true;
},
- _fetcCollectionAndEdit: function () {
+ _fetchCollectionAndEdit: function () {
const view = this;
const url = `${getAppRoot()}api/dataset_collections/${view.target.id}?instance_type=history`;
axios
diff --git a/lib/galaxy/files/__init__.py b/lib/galaxy/files/__init__.py
index a86664aee60d..89bfc92fefa3 100644
--- a/lib/galaxy/files/__init__.py
+++ b/lib/galaxy/files/__init__.py
@@ -68,7 +68,7 @@ def get_file_source_path(self, uri):
if "://" not in uri:
raise exceptions.RequestParameterInvalidException("Invalid uri [%s]" % uri)
scheme, rest = uri.split("://", 1)
- if scheme not in self.get_schemas():
+ if scheme not in self.get_schemes():
raise exceptions.RequestParameterInvalidException("Unsupported URI scheme [%s]" % scheme)
if scheme != "gxfiles":
@@ -107,30 +107,30 @@ def validate_uri_root(self, uri, user_context):
if not user_ftp_dir or not os.path.exists(user_ftp_dir):
raise exceptions.ObjectNotFound('Your FTP directory does not exist, attempting to upload files to it may cause it to be created.')
- def get_file_source(self, id_prefix, schema):
+ def get_file_source(self, id_prefix, scheme):
for file_source in self._file_sources:
- # gxfiles uses prefix to find plugin, other schema are assumed to have
+ # gxfiles uses prefix to find plugin, other scheme are assumed to have
# at most one file_source.
- if schema != file_source.get_schema():
+ if scheme != file_source.get_scheme():
continue
- prefix_match = schema != "gxfiles" or file_source.get_prefix() == id_prefix
+ prefix_match = scheme != "gxfiles" or file_source.get_prefix() == id_prefix
if prefix_match:
return file_source
def looks_like_uri(self, path_or_uri):
# is this string a URI this object understands how to realize
if path_or_uri.startswith("gx") and "://" in path_or_uri:
- for scheme in self.get_schemas():
+ for scheme in self.get_schemes():
if path_or_uri.startswith("%s://" % scheme):
return True
return False
- def get_schemas(self):
- schemas = set()
+ def get_schemes(self):
+ schemes = set()
for file_source in self._file_sources:
- schemas.add(file_source.get_schema())
- return schemas
+ schemes.add(file_source.get_scheme())
+ return schemes
def plugins_to_dict(self, for_serialization=False, user_context=None):
rval = []
diff --git a/lib/galaxy/files/sources/__init__.py b/lib/galaxy/files/sources/__init__.py
index 78dafe68e7bb..f92bda85e725 100644
--- a/lib/galaxy/files/sources/__init__.py
+++ b/lib/galaxy/files/sources/__init__.py
@@ -6,20 +6,27 @@
from galaxy.util.template import fill_template
+DEFAULT_SCHEME = "gxfiles"
+DEFAULT_WRITABLE = False
+
@six.add_metaclass(abc.ABCMeta)
class FilesSource(object):
"""
"""
- @abc.abstractproperty
+ @abc.abstractmethod
def get_uri_root(self):
"""Return a prefix for the root (e.g. gxfiles://prefix/)."""
- @abc.abstractproperty
- def get_schema(self):
+ @abc.abstractmethod
+ def get_scheme(self):
"""Return a prefix for the root (e.g. the gxfiles in gxfiles://prefix/path)."""
+ @abc.abstractmethod
+ def get_writable(self):
+ """Return a boolean indicating if this target is writable."""
+
# TODO: off-by-default
@abc.abstractmethod
def list(self, source_path="/", recursive=False, user_context=None):
@@ -29,6 +36,10 @@ def list(self, source_path="/", recursive=False, user_context=None):
def realize_to(self, source_path, native_path, user_context=None):
"""Realize source path (relative to uri root) to local file system path."""
+ def write_from(self, target_path, native_path, user_context=None):
+ """Write file at native path to target_path (relative to uri root).
+ """
+
@abc.abstractmethod
def to_dict(self, for_serialization=False, user_context=None):
"""Return a dictified representation of this FileSource instance.
@@ -43,13 +54,16 @@ class BaseFilesSource(FilesSource):
def get_prefix(self):
return self.id
- def get_schema(self):
+ def get_scheme(self):
return "gxfiles"
+ def get_writable(self):
+ return self.writable
+
def get_uri_root(self):
prefix = self.get_prefix()
- schema = self.get_schema()
- root = "%s://" % schema
+ scheme = self.get_scheme()
+ root = "%s://" % scheme
if prefix:
root = uri_join(root, prefix)
return root
@@ -63,7 +77,8 @@ def _parse_common_config_opts(self, kwd):
self.id = kwd.pop("id")
self.label = kwd.pop("label", None) or self.id
self.doc = kwd.pop("doc", None)
- self.schema = kwd.pop("schema", "gxfiles")
+ self.scheme = kwd.pop("scheme", DEFAULT_SCHEME)
+ self.writable = kwd.pop("writable", DEFAULT_WRITABLE)
# If coming from to_dict, strip API helper values
kwd.pop("uri_root", None)
kwd.pop("type", None)
@@ -76,6 +91,7 @@ def to_dict(self, for_serialization=False, user_context=None):
"uri_root": self.get_uri_root(),
"label": self.label,
"doc": self.doc,
+ "writable": self.writable,
}
if for_serialization:
rval.update(self._serialization_props(user_context=user_context))
@@ -96,6 +112,15 @@ def _serialization_props(self):
Used in to_dict method if for_serialization is True.
"""
+ def write_from(self, target_path, native_path, user_context=None):
+ if not self.get_writable():
+ raise Exception("Cannot write to a non-writable file source.")
+ self._write_from(target_path, native_path, user_context=user_context)
+
+ @abc.abstractmethod
+ def _write_from(self, target_path, native_path, user_context=None):
+ pass
+
def _evaluate_prop(self, prop_val, user_context):
rval = prop_val
if isinstance(prop_val, str) and "$" in prop_val:
diff --git a/lib/galaxy/files/sources/_pyfilesystem2.py b/lib/galaxy/files/sources/_pyfilesystem2.py
index 5f49eb5eb81c..941442d90364 100644
--- a/lib/galaxy/files/sources/_pyfilesystem2.py
+++ b/lib/galaxy/files/sources/_pyfilesystem2.py
@@ -42,6 +42,10 @@ def realize_to(self, source_path, native_path, user_context=None):
with open(native_path, 'wb') as write_file:
self._open_fs(user_context=user_context).download(source_path, write_file)
+ def _write_from(self, target_path, native_path, user_context=None):
+ with open(native_path, 'rb') as read_file:
+ self._open_fs(user_context=user_context).upload(target_path, read_file)
+
def _resource_info_to_dict(self, dir_path, resource_info):
name = resource_info.name
path = os.path.join(dir_path, name)
diff --git a/lib/galaxy/files/sources/galaxy.py b/lib/galaxy/files/sources/galaxy.py
index 6b0fdc1ccef1..7cc0ae02f63f 100644
--- a/lib/galaxy/files/sources/galaxy.py
+++ b/lib/galaxy/files/sources/galaxy.py
@@ -12,6 +12,7 @@ def __init__(self, label="FTP Directory", doc="Galaxy User's FTP Directory", roo
root=root,
label=label,
doc=doc,
+ writable=True,
)
posix_kwds.update(kwd)
if "delete_on_realize" not in posix_kwds:
@@ -22,7 +23,7 @@ def __init__(self, label="FTP Directory", doc="Galaxy User's FTP Directory", roo
def get_prefix(self):
return None
- def get_schema(self):
+ def get_scheme(self):
return "gxftp"
@@ -42,7 +43,7 @@ def __init__(self, label="Library Import Directory", doc="Galaxy's library impor
def get_prefix(self):
return None
- def get_schema(self):
+ def get_scheme(self):
return "gximport"
@@ -62,7 +63,7 @@ def __init__(self, label="Library User Import Directory", doc="Galaxy's user lib
def get_prefix(self):
return None
- def get_schema(self):
+ def get_scheme(self):
return "gxuserimport"
diff --git a/lib/galaxy/files/sources/posix.py b/lib/galaxy/files/sources/posix.py
index 96f611dff7ae..5c9140bf9161 100644
--- a/lib/galaxy/files/sources/posix.py
+++ b/lib/galaxy/files/sources/posix.py
@@ -62,6 +62,22 @@ def realize_to(self, source_path, native_path, user_context=None):
else:
shutil.move(source_native_path, native_path)
+ def _write_from(self, target_path, native_path, user_context=None):
+ effective_root = self._effective_root(user_context)
+ target_native_path = self._to_native_path(target_path, user_context=user_context)
+ if self.enforce_symlink_security:
+ if not safe_contains(effective_root, target_native_path, allowlist=self._allowlist):
+ raise Exception("Operation not allowed.")
+ else:
+ target_native_path = os.path.normpath(target_native_path)
+ assert target_native_path.startswith(os.path.normpath(effective_root))
+
+ target_native_path_parent = os.path.dirname(target_native_path)
+ if not os.path.exists(target_native_path_parent):
+ raise Exception("Parent directory does not exist.")
+
+ shutil.copyfile(native_path, target_native_path)
+
def _to_native_path(self, source_path, user_context=None):
source_path = os.path.normpath(source_path)
if source_path.startswith("/"):
diff --git a/lib/galaxy/tool_util/xsd/galaxy.xsd b/lib/galaxy/tool_util/xsd/galaxy.xsd
index 514aeed31e5d..a2c877c9317d 100644
--- a/lib/galaxy/tool_util/xsd/galaxy.xsd
+++ b/lib/galaxy/tool_util/xsd/galaxy.xsd
@@ -2398,6 +2398,14 @@ sanitizer to prevent Galaxy from escaping the result.
```
+#### ``directory_uri``
+
+This is used to tie into galaxy.files URI infrastructure. This should only be used by
+core Galaxy tools until the interface around files has stabilized.
+
+Currently ``directory_uri`` parameters provide user's the option of selecting a writable
+directory destination for unstructured outputs of tools (e.g. history exports).
+
$attribute_list:value,rgb:5
This covers examples of the most common parameter types, the remaining parameter
@@ -2693,6 +2701,7 @@ allow access to Python code to generate options for a select list. See
+
diff --git a/lib/galaxy/tools/__init__.py b/lib/galaxy/tools/__init__.py
index 1c37da97dfa2..5bf919c19268 100755
--- a/lib/galaxy/tools/__init__.py
+++ b/lib/galaxy/tools/__init__.py
@@ -128,6 +128,7 @@
"upload1",
"send_to_cloud",
"__DATA_FETCH__",
+ "directory_uri",
# Legacy tools bundled with Galaxy.
"laj_1",
"gff2bed1",
diff --git a/lib/galaxy/tools/parameters/basic.py b/lib/galaxy/tools/parameters/basic.py
index 739e6cf2be8b..1dc2a1a23df0 100644
--- a/lib/galaxy/tools/parameters/basic.py
+++ b/lib/galaxy/tools/parameters/basic.py
@@ -296,7 +296,26 @@ def parse_name(input_source):
return input_source.parse_name()
-class TextToolParameter(ToolParameter):
+class SimpleTextToolParameter(ToolParameter):
+
+ def __init__(self, tool, input_source):
+ input_source = ensure_input_source(input_source)
+ super().__init__(tool, input_source)
+ self.value = ''
+
+ def to_json(self, value, app, use_security):
+ """Convert a value to a string representation suitable for persisting"""
+ if value is None:
+ rval = ''
+ else:
+ rval = unicodify(value)
+ return rval
+
+ def get_initial_value(self, trans, other_values):
+ return self.value
+
+
+class TextToolParameter(SimpleTextToolParameter):
"""
Parameter that can take on any text value.
@@ -317,22 +336,11 @@ def __init__(self, tool, input_source):
self.value = input_source.get('value')
self.area = input_source.get_bool('area', False)
- def to_json(self, value, app, use_security):
- """Convert a value to a string representation suitable for persisting"""
- if value is None:
- rval = ''
- else:
- rval = unicodify(value)
- return rval
-
def validate(self, value, trans=None):
search = self.type == "text"
if not (trans and trans.workflow_building_mode is workflow_building_modes.ENABLED and contains_workflow_parameter(value, search=search)):
return super().validate(value, trans)
- def get_initial_value(self, trans, other_values):
- return self.value
-
def to_dict(self, trans, other_values={}):
d = super().to_dict(trans)
d['area'] = self.area
@@ -2328,6 +2336,14 @@ def to_python(self, value, app):
return json.loads(value)
+class DirectoryUriToolParameter(SimpleTextToolParameter):
+ """galaxy.files URIs for directories."""
+
+ def __init__(self, tool, input_source, context=None):
+ input_source = ensure_input_source(input_source)
+ SimpleTextToolParameter.__init__(self, tool, input_source)
+
+
class RulesListToolParameter(BaseJsonToolParameter):
"""
Parameter that allows for the creation of a list of rules using the Galaxy rules DSL.
@@ -2388,6 +2404,7 @@ def to_text(self, value):
data_collection=DataCollectionToolParameter,
library_data=LibraryDatasetToolParameter,
rules=RulesListToolParameter,
+ directory_uri=DirectoryUriToolParameter,
drill_down=DrillDownSelectToolParameter
)
diff --git a/test/functional/tools/directory_uri.xml b/test/functional/tools/directory_uri.xml
new file mode 100644
index 000000000000..610674e7c3c7
--- /dev/null
+++ b/test/functional/tools/directory_uri.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/functional/tools/directory_uri_copy_to.py b/test/functional/tools/directory_uri_copy_to.py
new file mode 100644
index 000000000000..8a9883414617
--- /dev/null
+++ b/test/functional/tools/directory_uri_copy_to.py
@@ -0,0 +1,46 @@
+import argparse
+import json
+import os
+import sys
+import tempfile
+
+from galaxy.files import ConfiguredFileSources
+
+
+def get_file_sources(file_sources_path):
+ assert os.path.exists(file_sources_path), "file sources path [%s] does not exist" % file_sources_path
+ with open(file_sources_path, "r") as f:
+ file_sources_as_dict = json.load(f)
+ file_sources = ConfiguredFileSources.from_dict(file_sources_as_dict)
+ return file_sources
+
+
+def main(argv=None):
+ if argv is None:
+ argv = sys.argv[1:]
+
+ args = _parser().parse_args(argv)
+ file_sources = get_file_sources(args.file_sources)
+ directory_uri = args.directory_uri
+ if directory_uri.endswith("/"):
+ target_uri = directory_uri + "helloworld"
+ else:
+ target_uri = directory_uri + "/helloworld"
+ file_source_path = file_sources.get_file_source_path(target_uri)
+ file_source = file_source_path.file_source
+ fd, temp_name = tempfile.mkstemp()
+ with open(fd, 'w') as f:
+ f.write('hello world!\n')
+ file_source.write_from(file_source_path.path, temp_name)
+
+
+def _parser():
+ parser = argparse.ArgumentParser()
+ parser.add_argument('--directory_uri', type=str,
+ help='directory target URI')
+ parser.add_argument('--file_sources', type=str, help='file sources json')
+ return parser
+
+
+if __name__ == "__main__":
+ main()
diff --git a/test/functional/tools/samples_tool_conf.xml b/test/functional/tools/samples_tool_conf.xml
index 358c850dab68..2f5d6a21de63 100644
--- a/test/functional/tools/samples_tool_conf.xml
+++ b/test/functional/tools/samples_tool_conf.xml
@@ -62,6 +62,7 @@
+
diff --git a/test/integration/test_remote_files.py b/test/integration/test_remote_files.py
index b60f40d1e1c9..99325ca79913 100644
--- a/test/integration/test_remote_files.py
+++ b/test/integration/test_remote_files.py
@@ -18,6 +18,8 @@
class RemoteFilesIntegrationTestCase(integration_util.IntegrationTestCase):
+ framework_tool_and_types = True
+
@classmethod
def handle_galaxy_config_kwds(cls, config):
root = os.path.realpath(mkdtemp())
@@ -109,6 +111,20 @@ def test_fetch_from_ftp(self):
assert not os.path.exists(os.path.join(ftp_dir, "a"))
+ def test_write_to_files(self):
+ dataset_populator = self.dataset_populator
+ ftp_dir = os.path.join(self.ftp_upload_dir, USER_EMAIL)
+ _write_file_fixtures(self.root, ftp_dir)
+ with dataset_populator.test_history() as history_id:
+ inputs = {
+ "d_uri": "gxftp://",
+ }
+ response = dataset_populator.run_tool("directory_uri", inputs, history_id)
+ dataset_populator.wait_for_job(response["jobs"][0]["id"])
+ assert 'helloworld' in os.listdir(ftp_dir)
+ with open(os.path.join(ftp_dir, 'helloworld'), 'r') as f:
+ assert 'hello world!\n' == f.read()
+
def _assert_index_empty(self, index):
assert len(index) == 0
diff --git a/test/unit/files/_util.py b/test/unit/files/_util.py
index acaa4fd1bcff..15ebe01217de 100644
--- a/test/unit/files/_util.py
+++ b/test/unit/files/_util.py
@@ -66,7 +66,22 @@ def assert_realizes_as(file_sources, uri, expected, user_context=None):
file_source_path.file_source.realize_to(file_source_path.path, temp_name, user_context=user_context)
try:
with open(temp_name, "r") as f:
- assert f.read() == expected
+ realized_contents = f.read()
+ if realized_contents != expected:
+ message = "Expected to realize contents at [{}] as [{}], instead found [{}]".format(
+ uri,
+ expected,
+ realized_contents,
+ )
+ raise AssertionError(message)
finally:
os.remove(temp_name)
return temp_name
+
+
+def write_from(file_sources, uri, content, user_context=None):
+ file_source_path = file_sources.get_file_source_path(uri)
+ fd, temp_name = tempfile.mkstemp()
+ with open(fd, 'w') as f:
+ f.write(content)
+ file_source_path.file_source.write_from(file_source_path.path, temp_name, user_context=user_context)
diff --git a/test/unit/files/posix.py b/test/unit/files/posix.py
index d9c5c59c8676..9acf372f723b 100644
--- a/test/unit/files/posix.py
+++ b/test/unit/files/posix.py
@@ -14,6 +14,7 @@
list_root,
serialize_and_recover,
user_context_fixture,
+ write_from,
)
EMAIL = 'alice@galaxyproject.org'
@@ -63,7 +64,17 @@ def test_posix_link_security():
sniff.stream_url_to_file("gxfiles://test1/unsafe", file_sources=file_sources)
except Exception as ex:
e = ex
- assert e is not None
+ _assert_access_prohibited(e)
+
+
+def test_posix_link_security_write():
+ file_sources = _configured_file_sources(writable=True)
+ e = None
+ try:
+ write_from(file_sources, "gxfiles://test1/unsafe", "my test content")
+ except Exception as ex:
+ e = ex
+ _assert_access_prohibited(e)
def test_posix_link_security_allowlist():
@@ -76,6 +87,13 @@ def test_posix_link_security_allowlist():
os.remove(tmp_name)
+def test_posix_link_security_allowlist_write():
+ file_sources = _configured_file_sources(include_allowlist=True, writable=True)
+ write_from(file_sources, "gxfiles://test1/unsafe", "my test content")
+ with open(os.path.join(file_sources.test_root, "unsafe"), "r") as f:
+ assert f.read() == "my test content"
+
+
def test_posix_disable_link_security():
file_sources = _configured_file_sources(plugin_extra_config={"enforce_symlink_security": False})
tmp_name = sniff.stream_url_to_file("gxfiles://test1/unsafe", file_sources=file_sources)
@@ -86,6 +104,17 @@ def test_posix_disable_link_security():
os.remove(tmp_name)
+def test_posix_nonexistent_parent_write():
+ file_sources = _configured_file_sources(include_allowlist=True, writable=True)
+ e = None
+ try:
+ write_from(file_sources, "gxfiles://test1/notreal/myfile", "my test content")
+ except Exception as ex:
+ e = ex
+ assert e is not None
+ assert "Parent" in str(e)
+
+
def test_posix_per_user():
file_sources = _configured_file_sources(per_user=True)
user_context = user_context_fixture()
@@ -95,6 +124,23 @@ def test_posix_per_user():
assert find_file_a(res)
+def test_posix_per_user_writable():
+ file_sources = _configured_file_sources(per_user=True, writable=True)
+ user_context = user_context_fixture()
+
+ res = list_root(file_sources, "gxfiles://test1", recursive=False, user_context=user_context)
+ b = find(res, name="b")
+ assert b is None
+
+ write_from(file_sources, "gxfiles://test1/b", "my test content", user_context=user_context)
+
+ res = list_root(file_sources, "gxfiles://test1", recursive=False, user_context=user_context)
+ b = find(res, name="b")
+ assert b is not None, b
+
+ assert_realizes_as(file_sources, "gxfiles://test1/b", "my test content", user_context=user_context)
+
+
def test_posix_per_user_serialized():
user_context = user_context_fixture()
file_sources = serialize_and_recover(_configured_file_sources(per_user=True), user_context=user_context)
@@ -208,7 +254,7 @@ def test_user_import_dir_implicit_config():
assert_realizes_as(file_sources, "gxuserimport://a", "a\n", user_context=user_context)
-def _configured_file_sources(include_allowlist=False, plugin_extra_config=None, per_user=False):
+def _configured_file_sources(include_allowlist=False, plugin_extra_config=None, per_user=False, writable=None):
tmp, root = _setup_root()
config_kwd = {}
if include_allowlist:
@@ -218,6 +264,8 @@ def _configured_file_sources(include_allowlist=False, plugin_extra_config=None,
'type': 'posix',
'id': 'test1',
}
+ if writable is not None:
+ plugin['writable'] = writable
if per_user:
plugin['root'] = "%s/${user.username}" % root
# setup files just for alice
@@ -227,7 +275,9 @@ def _configured_file_sources(include_allowlist=False, plugin_extra_config=None,
plugin['root'] = root
plugin.update(plugin_extra_config or {})
_write_file_fixtures(tmp, root)
- return ConfiguredFileSources(file_sources_config, conf_dict=[plugin])
+ file_sources = ConfiguredFileSources(file_sources_config, conf_dict=[plugin])
+ file_sources.test_root = root
+ return file_sources
def _setup_root():
@@ -266,3 +316,8 @@ def _download_and_check_file(file_sources):
assert a_contents == "a\n"
finally:
os.remove(tmp_name)
+
+
+def _assert_access_prohibited(e):
+ assert e is not None
+ assert "Operation not allowed" in str(e)