From 55e006b5b1bc375ffa41b5b869627f5b297f5452 Mon Sep 17 00:00:00 2001 From: Arvind Kumar Date: Wed, 11 Nov 2026 02:51:42 +0530 Subject: [PATCH] Fixed an issue where Import/Export Data failed for a file name containing a backslash. #10672 --- web/pgadmin/tools/import_export/__init__.py | 16 ++++ .../import_export/sql/16_plus/cmd.sql | 2 +- .../import_export/sql/17_plus/cmd.sql | 2 +- .../import_export/sql/default/cmd.sql | 2 +- .../tests/test_import_export_copy_filename.py | 95 +++++++++++++++++++ 5 files changed, 114 insertions(+), 3 deletions(-) create mode 100644 web/pgadmin/tools/import_export/tests/test_import_export_copy_filename.py diff --git a/web/pgadmin/tools/import_export/__init__.py b/web/pgadmin/tools/import_export/__init__.py index cfeef9aeb1d..2e63bab841d 100644 --- a/web/pgadmin/tools/import_export/__init__.py +++ b/web/pgadmin/tools/import_export/__init__.py @@ -267,6 +267,19 @@ def _save_import_export_settings(settings): _VALID_LOG_VERBOSITY = frozenset({'default', 'verbose'}) +def _copy_filename_literal(filename): + """The file name as psql's \\copy expects it: wrapped in single quotes + with only an embedded single quote doubled. + + psql parses the file name of \\copy itself, and that token has no + backslash escapes and no E'...' form. qtLiteral() must not be used for + it: for a value containing a backslash it returns an escape-string + literal, which psql reads as a file named "E" followed by text it + passes on to the server. + """ + return "'" + filename.replace("'", "''") + "'" + + def _identifier_strings(value): """The identifier strings columns_to_string() would feed to qtIdent() for value, whatever JSON type it arrives as. A list yields its string @@ -593,6 +606,8 @@ def create_import_export_job(sid): # Create the COPY FROM/TO from template temp_path = 'import_export/sql/#{0}#/cmd.sql'.format(manager.version) query = render_template(temp_path, conn=conn, data=data, columns=cols, + filename=_copy_filename_literal( + data['filename']), not_null_columns=not_null_cols, null_columns=null_cols, force_quote_columns=quote_cols) @@ -618,6 +633,7 @@ def create_import_export_job(sid): try: launch_query = render_template( temp_path, conn=conn, data=dict(data, filename=utility_file), + filename=_copy_filename_literal(utility_file), columns=cols, not_null_columns=not_null_cols, null_columns=null_cols, force_quote_columns=quote_cols) except Exception: diff --git a/web/pgadmin/tools/import_export/templates/import_export/sql/16_plus/cmd.sql b/web/pgadmin/tools/import_export/templates/import_export/sql/16_plus/cmd.sql index 439601e2ded..f14f2d22baa 100644 --- a/web/pgadmin/tools/import_export/templates/import_export/sql/16_plus/cmd.sql +++ b/web/pgadmin/tools/import_export/templates/import_export/sql/16_plus/cmd.sql @@ -1 +1 @@ -\copy {% if data.query %}({{ data.query }}) {% else %}{{ conn|qtIdent(data.schema, data.table) }}{% if columns %}{{ columns }} {% endif %}{% endif %} {% if data.is_import %}FROM{% else %}TO{% endif %} {{ data.filename|qtLiteral(conn) }} WITH(FORMAT {{data.format}}{% if data.delimiter and data.format != 'binary' and data.delimiter == '[tab]' %}, DELIMITER E'\t'{% elif data.format != 'binary' and data.delimiter %}, DELIMITER {{ data.delimiter|qtLiteral(conn) }}{% endif %}{% if data.format == 'csv' and data.header %}, HEADER{% endif %}{% if data.freeze %}, FREEZE{% endif %}{% if data.encoding %}, ENCODING {{ data.encoding|qtLiteral(conn) }}{% endif %}{% if data.format == 'csv' and data.quote %}, QUOTE {{ data.quote|qtLiteral(conn) }}{% endif %}{% if data.format != 'binary' and data.null_string %}, NULL {{ data.null_string|qtLiteral(conn) }}{% endif %}{% if data.format != 'binary' and data.is_import and data.default_string %}, DEFAULT {{ data.default_string|qtLiteral(conn) }}{% endif %}{% if data.format == 'csv' and data.escape %}, ESCAPE {{ data.escape|qtLiteral(conn) }}{% endif %}{% if data.format == 'csv' and data.is_import and not_null_columns %}, FORCE_NOT_NULL {{ not_null_columns }}{% endif %}{% if data.format == 'csv' and data.is_import and null_columns %}, FORCE_NULL {{ null_columns }}{% endif %}{% if data.format == 'csv' and not data.is_import and force_quote_columns %}, FORCE_QUOTE {{ force_quote_columns }}{% endif %}); +\copy {% if data.query %}({{ data.query }}) {% else %}{{ conn|qtIdent(data.schema, data.table) }}{% if columns %}{{ columns }} {% endif %}{% endif %} {% if data.is_import %}FROM{% else %}TO{% endif %} {{ filename }} WITH(FORMAT {{data.format}}{% if data.delimiter and data.format != 'binary' and data.delimiter == '[tab]' %}, DELIMITER E'\t'{% elif data.format != 'binary' and data.delimiter %}, DELIMITER {{ data.delimiter|qtLiteral(conn) }}{% endif %}{% if data.format == 'csv' and data.header %}, HEADER{% endif %}{% if data.freeze %}, FREEZE{% endif %}{% if data.encoding %}, ENCODING {{ data.encoding|qtLiteral(conn) }}{% endif %}{% if data.format == 'csv' and data.quote %}, QUOTE {{ data.quote|qtLiteral(conn) }}{% endif %}{% if data.format != 'binary' and data.null_string %}, NULL {{ data.null_string|qtLiteral(conn) }}{% endif %}{% if data.format != 'binary' and data.is_import and data.default_string %}, DEFAULT {{ data.default_string|qtLiteral(conn) }}{% endif %}{% if data.format == 'csv' and data.escape %}, ESCAPE {{ data.escape|qtLiteral(conn) }}{% endif %}{% if data.format == 'csv' and data.is_import and not_null_columns %}, FORCE_NOT_NULL {{ not_null_columns }}{% endif %}{% if data.format == 'csv' and data.is_import and null_columns %}, FORCE_NULL {{ null_columns }}{% endif %}{% if data.format == 'csv' and not data.is_import and force_quote_columns %}, FORCE_QUOTE {{ force_quote_columns }}{% endif %}); diff --git a/web/pgadmin/tools/import_export/templates/import_export/sql/17_plus/cmd.sql b/web/pgadmin/tools/import_export/templates/import_export/sql/17_plus/cmd.sql index c6406154422..ddf62f7f155 100644 --- a/web/pgadmin/tools/import_export/templates/import_export/sql/17_plus/cmd.sql +++ b/web/pgadmin/tools/import_export/templates/import_export/sql/17_plus/cmd.sql @@ -1 +1 @@ -\copy {% if data.query %}({{ data.query }}) {% else %}{{ conn|qtIdent(data.schema, data.table) }}{% if columns %}{{ columns }} {% endif %}{% endif %} {% if data.is_import %}FROM{% else %}TO{% endif %} {{ data.filename|qtLiteral(conn) }} WITH(FORMAT {{data.format}}{% if data.delimiter and data.format != 'binary' and data.delimiter == '[tab]' %}, DELIMITER E'\t'{% elif data.format != 'binary' and data.delimiter %}, DELIMITER {{ data.delimiter|qtLiteral(conn) }}{% endif %}{% if data.format == 'csv' and data.header %}, HEADER{% endif %}{% if data.freeze %}, FREEZE{% endif %}{% if data.encoding %}, ENCODING {{ data.encoding|qtLiteral(conn) }}{% endif %}{% if data.format == 'csv' and data.quote %}, QUOTE {{ data.quote|qtLiteral(conn) }}{% endif %}{% if data.format != 'binary' and data.null_string %}, NULL {{ data.null_string|qtLiteral(conn) }}{% endif %}{% if data.format != 'binary' and data.is_import and data.default_string %}, DEFAULT {{ data.default_string|qtLiteral(conn) }}{% endif %}{% if data.format == 'csv' and data.escape %}, ESCAPE {{ data.escape|qtLiteral(conn) }}{% endif %}{% if data.format == 'csv' and data.is_import and not_null_columns %}, FORCE_NOT_NULL {{ not_null_columns }}{% endif %}{% if data.format == 'csv' and data.is_import and null_columns %}, FORCE_NULL {{ null_columns }}{% endif %}{% if data.format == 'csv' and not data.is_import and force_quote_columns %}, FORCE_QUOTE {{ force_quote_columns }}{% endif %}{% if data.format != 'binary' and data.is_import and data.on_error == 'ignore' %}, ON_ERROR {{ data.on_error }}{% endif %}{% if data.format != 'binary' and data.is_import and data.log_verbosity and data.on_error == 'ignore' %}, LOG_VERBOSITY {{ data.log_verbosity }}{% endif %}); +\copy {% if data.query %}({{ data.query }}) {% else %}{{ conn|qtIdent(data.schema, data.table) }}{% if columns %}{{ columns }} {% endif %}{% endif %} {% if data.is_import %}FROM{% else %}TO{% endif %} {{ filename }} WITH(FORMAT {{data.format}}{% if data.delimiter and data.format != 'binary' and data.delimiter == '[tab]' %}, DELIMITER E'\t'{% elif data.format != 'binary' and data.delimiter %}, DELIMITER {{ data.delimiter|qtLiteral(conn) }}{% endif %}{% if data.format == 'csv' and data.header %}, HEADER{% endif %}{% if data.freeze %}, FREEZE{% endif %}{% if data.encoding %}, ENCODING {{ data.encoding|qtLiteral(conn) }}{% endif %}{% if data.format == 'csv' and data.quote %}, QUOTE {{ data.quote|qtLiteral(conn) }}{% endif %}{% if data.format != 'binary' and data.null_string %}, NULL {{ data.null_string|qtLiteral(conn) }}{% endif %}{% if data.format != 'binary' and data.is_import and data.default_string %}, DEFAULT {{ data.default_string|qtLiteral(conn) }}{% endif %}{% if data.format == 'csv' and data.escape %}, ESCAPE {{ data.escape|qtLiteral(conn) }}{% endif %}{% if data.format == 'csv' and data.is_import and not_null_columns %}, FORCE_NOT_NULL {{ not_null_columns }}{% endif %}{% if data.format == 'csv' and data.is_import and null_columns %}, FORCE_NULL {{ null_columns }}{% endif %}{% if data.format == 'csv' and not data.is_import and force_quote_columns %}, FORCE_QUOTE {{ force_quote_columns }}{% endif %}{% if data.format != 'binary' and data.is_import and data.on_error == 'ignore' %}, ON_ERROR {{ data.on_error }}{% endif %}{% if data.format != 'binary' and data.is_import and data.log_verbosity and data.on_error == 'ignore' %}, LOG_VERBOSITY {{ data.log_verbosity }}{% endif %}); diff --git a/web/pgadmin/tools/import_export/templates/import_export/sql/default/cmd.sql b/web/pgadmin/tools/import_export/templates/import_export/sql/default/cmd.sql index 933855bb157..75021a4ba98 100644 --- a/web/pgadmin/tools/import_export/templates/import_export/sql/default/cmd.sql +++ b/web/pgadmin/tools/import_export/templates/import_export/sql/default/cmd.sql @@ -1 +1 @@ -\copy {% if data.query %}({{ data.query }}) {% else %}{{ conn|qtIdent(data.schema, data.table) }}{% if columns %}{{ columns }} {% endif %}{% endif %} {% if data.is_import %}FROM{% else %}TO{% endif %} {{ data.filename|qtLiteral(conn) }} WITH(FORMAT {{data.format}}{% if data.delimiter and data.format != 'binary' and data.delimiter == '[tab]' %}, DELIMITER E'\t'{% elif data.format != 'binary' and data.delimiter %}, DELIMITER {{ data.delimiter|qtLiteral(conn) }}{% endif %}{% if data.format == 'csv' and data.header %}, HEADER{% endif %}{% if data.freeze %}, FREEZE{% endif %}{% if data.encoding %}, ENCODING {{ data.encoding|qtLiteral(conn) }}{% endif %}{% if data.format == 'csv' and data.quote %}, QUOTE {{ data.quote|qtLiteral(conn) }}{% endif %}{% if data.format != 'binary' and data.null_string %}, NULL {{ data.null_string|qtLiteral(conn) }}{% endif %}{% if data.format == 'csv' and data.escape %}, ESCAPE {{ data.escape|qtLiteral(conn) }}{% endif %}{% if data.format == 'csv' and data.is_import and not_null_columns %}, FORCE_NOT_NULL {{ not_null_columns }}{% endif %}{% if data.format == 'csv' and data.is_import and null_columns %}, FORCE_NULL {{ null_columns }}{% endif %}{% if data.format == 'csv' and not data.is_import and force_quote_columns %}, FORCE_QUOTE {{ force_quote_columns }}{% endif %}); +\copy {% if data.query %}({{ data.query }}) {% else %}{{ conn|qtIdent(data.schema, data.table) }}{% if columns %}{{ columns }} {% endif %}{% endif %} {% if data.is_import %}FROM{% else %}TO{% endif %} {{ filename }} WITH(FORMAT {{data.format}}{% if data.delimiter and data.format != 'binary' and data.delimiter == '[tab]' %}, DELIMITER E'\t'{% elif data.format != 'binary' and data.delimiter %}, DELIMITER {{ data.delimiter|qtLiteral(conn) }}{% endif %}{% if data.format == 'csv' and data.header %}, HEADER{% endif %}{% if data.freeze %}, FREEZE{% endif %}{% if data.encoding %}, ENCODING {{ data.encoding|qtLiteral(conn) }}{% endif %}{% if data.format == 'csv' and data.quote %}, QUOTE {{ data.quote|qtLiteral(conn) }}{% endif %}{% if data.format != 'binary' and data.null_string %}, NULL {{ data.null_string|qtLiteral(conn) }}{% endif %}{% if data.format == 'csv' and data.escape %}, ESCAPE {{ data.escape|qtLiteral(conn) }}{% endif %}{% if data.format == 'csv' and data.is_import and not_null_columns %}, FORCE_NOT_NULL {{ not_null_columns }}{% endif %}{% if data.format == 'csv' and data.is_import and null_columns %}, FORCE_NULL {{ null_columns }}{% endif %}{% if data.format == 'csv' and not data.is_import and force_quote_columns %}, FORCE_QUOTE {{ force_quote_columns }}{% endif %}); diff --git a/web/pgadmin/tools/import_export/tests/test_import_export_copy_filename.py b/web/pgadmin/tools/import_export/tests/test_import_export_copy_filename.py new file mode 100644 index 00000000000..de5977df2a0 --- /dev/null +++ b/web/pgadmin/tools/import_export/tests/test_import_export_copy_filename.py @@ -0,0 +1,95 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2026, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +import os + +from jinja2 import Environment, FileSystemLoader + +from pgadmin.utils.route import BaseTestGenerator + +TEMPLATES_DIR = os.path.join( + os.path.dirname(os.path.dirname(os.path.realpath(__file__))), + 'templates', 'import_export', 'sql') + + +class IECopyFilenameLiteralTest(BaseTestGenerator): + """Test the _copy_filename_literal function. + + psql parses the file name of \\copy itself: the token is optionally + wrapped in single quotes, an embedded single quote is doubled, and + there are no backslash escapes and no E'...' form. A backslash must + therefore reach psql unchanged, in a plain '...' literal. + """ + + scenarios = [ + ('Plain file name', dict( + filename='/tmp/a.csv', expected="'/tmp/a.csv'")), + ('File name with a space', dict( + filename='/tmp/a b.csv', expected="'/tmp/a b.csv'")), + ('Backslash is left as it is', dict( + filename='/tmp/a\\b.csv', expected="'/tmp/a\\b.csv'")), + ('Trailing backslash does not escape the closing quote', dict( + filename='/tmp/a\\', expected="'/tmp/a\\'")), + ('Single quote is doubled', dict( + filename="/tmp/a'b.csv", expected="'/tmp/a''b.csv'")), + ('Backslash before a single quote', dict( + filename="/tmp/a\\'b.csv", expected="'/tmp/a\\''b.csv'")), + ('File descriptor path', dict( + filename='/dev/fd/7', expected="'/dev/fd/7'")), + ] + + def runTest(self): + from pgadmin.tools.import_export import _copy_filename_literal + self.assertEqual( + _copy_filename_literal(self.filename), self.expected) + + +class IECopyFilenameTemplateTest(BaseTestGenerator): + """The \\copy templates must write the file name they are given as it + is, and must not pass it through qtLiteral(), which turns a value + containing a backslash into an E'...' literal that psql reads as a + file named "E". + """ + + scenarios = [ + ('Export, {0} template'.format(version), dict( + version=version, is_import=False)) + for version in ('default', '16_plus', '17_plus') + ] + [ + ('Import, {0} template'.format(version), dict( + version=version, is_import=True)) + for version in ('default', '16_plus', '17_plus') + ] + + def runTest(self): + from pgadmin.tools.import_export import _copy_filename_literal + + env = Environment(loader=FileSystemLoader(TEMPLATES_DIR)) + env.filters['qtIdent'] = lambda conn, *names: '.'.join(names) + env.filters['qtLiteral'] = \ + lambda value, conn: 'qtLiteral({0})'.format(value) + + filename = '/tmp/a\\b.csv' + data = dict( + schema='public', table='t', is_import=self.is_import, + filename=filename, format='csv', delimiter=',', + encoding='UTF8') + cmd = env.get_template(self.version + '/cmd.sql').render( + conn=None, data=data, columns=None, + filename=_copy_filename_literal(filename), + not_null_columns=None, null_columns=None, + force_quote_columns=None) + + self.assertIn( + "{0} '/tmp/a\\b.csv' WITH(FORMAT csv".format( + 'FROM' if self.is_import else 'TO'), cmd) + self.assertNotIn("E'/tmp", cmd) + self.assertNotIn('qtLiteral(/tmp', cmd) + # The option values are parsed by the server and keep qtLiteral(). + self.assertIn('DELIMITER qtLiteral(,)', cmd)