16 Commits
1.0 ... 1.3

Author SHA1 Message Date
a212514bbf Use latin1 encoding as a default for Amiga file names. 2023-10-21 09:49:33 +02:00
158bc83a14 Clean up code. 2023-10-20 18:37:43 +02:00
103714e9c6 Fix the strings/bytes mess with shutil 2023-10-20 18:36:47 +02:00
733b8c6d12 Correct extfslib url. 2022-10-02 10:31:16 +02:00
2d1c387f38 Separate ulzx from extfslib 2022-10-02 10:27:12 +02:00
01b4bf7a41 Adapt ulzx to python3 2019-06-30 19:18:03 +02:00
6b8684b1d7 Update uadf to support py3 2019-06-30 17:06:56 +02:00
2e50401a18 Bump version 2019-06-30 16:52:33 +02:00
f0a9e5f85d Fixes for ulha rmdir and copyin commands 2019-06-30 15:51:41 +02:00
3d269303d9 Added support for Python 3 for ulha
ulzx and uadf currently are broken. Fix for both of them is on the way.
2019-06-27 21:38:53 +02:00
0a972a5bce Readme update 2015-09-03 19:54:57 +02:00
e6e8b5c74a Added missing license information 2015-09-02 21:23:54 +02:00
b84d191632 Fixed destination directory for plugins in docs. 2015-09-02 21:20:32 +02:00
ffb99d6515 Moved common attributes to extfslib, uadf now will complain on nodos or
corrupted images.
2013-05-16 22:25:28 +02:00
c8407ff57b Reformated README 2013-05-12 18:23:49 +02:00
dc7f625542 Renamed repository. Added common lib for all extfs plugins.
Added uadf and ulzx.
2013-05-12 18:14:20 +02:00
4 changed files with 212 additions and 340 deletions

24
LICENSE Normal file
View File

@@ -0,0 +1,24 @@
Copyright (c) 2013, Roman Dobosz
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
* Neither the name of the organization nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL ROMAN DOBOSZ BE LIABLE FOR ANY DIRECT, INDIRECT,
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@@ -1,16 +1,49 @@
=================================
ulha extfs for Midnight Commander
=================================
=======================
Midnight Commander ulzx
=======================
This is Midnight Commander extfs plugin for handling lha/lzh archives.
It requires `lha <http://lha.sourceforge.jp>`_ free LHA implementation to work.
Midnight Commander extfs plugin for handling lzx Amiga archives.
Description
===========
ULzx is an extfs plugin which can be used to browse and extract lzx archives,
which are known almost exclusively from Amiga.
Due to limitations of
`unlzx <ftp://us.aminet.net/pub/aminet/misc/unix/unlzx.c.gz.readme>`_ tools,
only reading is supported. Also be aware, that
`unlzx <ftp://us.aminet.net/pub/aminet/misc/unix/unlzx.c.gz.readme>`_ cannot
extract files individually, so copying entire archive content is not
recommended, since on every single file a full archive extract would be
done, which in the end would have impact on performance.
Requirements
------------
ULzx requires
`unlzx <ftp://us.aminet.net/pub/aminet/misc/unix/unlzx.c.gz.readme>`_ tool.
Installation
------------
* copy ``ulha.py`` to ``~/.local/share/mc/extfs/ulha``
* add or change entry for lha/lzh files handle in ``~/.config/mc/mc.ext``::
# lha
regex/\.[lL]([Hh][aA]|[Zz][hH])$
Open=%cd %p/ulha://
View=%view{ascii} lha l %f
* install `extfslib`_
* copy ``ulzx`` to ``~/.local/share/mc/extfs.d/``
* add or change entry for files handle in ``~/.config/mc/mc.ext``:
.. code:: ini
[lzx]
Regex=\.lzx$
RegexIgnoreCase=true
Open=%cd %p/ulzx://
View=%view{ascii} unlzx -v %f
License
=======
This software is licensed under 3-clause BSD license. See LICENSE file for
details.
.. _extfslib: https://github.com/gryf/mc_extfslib

329
ulha
View File

@@ -1,329 +0,0 @@
#! /usr/bin/env python
"""
Lha Virtual filesystem executive for Midnight Commander.
Tested against python 2.7, lha[1] 1.14 and mc 4.8.7
[1] http://lha.sourceforge.jp
Changelog:
1.0 Initial release
Author: Roman 'gryf' Dobosz <gryf73@gmail.com>
Date: 2013-05-05
Version: 1.0
Licence: BSD
"""
import os
import sys
import re
import shutil
from subprocess import call, check_call, check_output, CalledProcessError
from tempfile import mkdtemp, mkstemp
# Define which archiver you are using with appropriate options
ARCHIVER = "lha"
CMDS = {"list": "lq",
"read": "pq",
"write": "aq",
"delete": "dq"}
LINE_LHD = re.compile("^(?P<perms>[d-][rswx-]{9})"
"\s+(?P<uid>\d+)/"
"(?P<gid>\d+)"
"\s+(?P<size>\d+)"
"\s+(\*{6}|\d+\.\d%)"
"\s(?P<month>[JFMASOND][a-z]{2})\s+" # month
"(?P<day>\d+)\s+" # day
"(?P<yh>\d{4}|(\d{2}:\d{2}))" # year/hour
"\s(?P<fpath>.*)")
LINE_LHx = re.compile("^(?P<perms>(\[generic\])|(\[unknown\])|([d-][rswx-]{9}))"
"\s+(?P<size>\d+)"
"\s+(\*{6}|\d+\.\d%)"
"\s(?P<month>[JFMASOND][a-z]{2})\s+" # month
"(?P<day>\d+)\s+" # day
"(?P<yh>\d{4}|(\d{2}:\d{2}))" # year/hour
"\s(?P<fpath>.*)")
class Archive(object):
"""Archive handle. Provides interface to MC's extfs subsystem"""
def __init__(self, fname):
"""Prepare archive content for operations"""
self._filemap = {}
self._arch = fname
self._pattern = None
self._uid = str(os.getuid())
self._gid = str(os.getgid())
self._contents = self._get_dir()
def _identify(self):
"""Check for lha header"""
pat_map = {"-lhd-": LINE_LHD,
"-lh0-": LINE_LHx,
"-lh1-": LINE_LHx,
"-lh5-": LINE_LHx,
"-lh6-": LINE_LHx}
fobj = open(self._arch)
fobj.seek(2)
ident = fobj.read(5)
fobj.close()
return pat_map[ident]
def _map_name(self, name):
"""MC still have a bug in extfs subsystem, in case of filepaths with
leading space. This is workaround to this bug, which replaces leading
space with tilda. Real name is remembered in _filemap attribute and
used in real operations."""
if name.startswith(" "):
new_name = "".join(["~", name[1:]])
self._filemap[new_name] = name
return new_name
return name
def _get_real_name(self, name):
"""Get real filepath of the file. See _map_name docstring for
details."""
new_name = self._filemap.get(name)
if new_name:
return new_name
return name
def _get_dir(self):
"""Prepare archive file listing"""
if not self._pattern:
self._pattern = self._identify()
self._filemap = {}
contents = []
if self._pattern == LINE_LHx:
perms = "-rw-r--r--"
out = self._call_command("list")
if not out:
return
for line in out.split("\n"):
if line.endswith("/"):
line = line[:-1]
if self._pattern == LINE_LHx:
perms = "drw-r--r--"
match = self._pattern.match(line)
if not match:
continue
entry = match.groupdict()
# UID and GID sometimes can have strange values depending on
# the information that was written into archive. Most of the
# times I was dealing with Amiga lha archives, so that i don't
# really care about real user/group
entry['uid'] = self._uid
entry['gid'] = self._gid
if self._pattern == LINE_LHx:
entry['perms'] = perms
entry['display_name'] = self._map_name(entry['fpath'])
contents.append(entry)
return contents
def _call_command(self, cmd, src=None, dst=None):
"""
Return status of the provided command, which can be one of:
write
read
delete
list
"""
command = [ARCHIVER, CMDS.get(cmd), self._arch]
if src and dst:
command.append(src)
command.append(dst)
elif src or dst:
command.append(src and src or dst)
try:
output = check_output(command)
except CalledProcessError:
return None
return output
def list(self):
"""Output contents of the archive to stdout"""
for entry in self._contents:
sys.stdout.write("%(perms)s 1 %(uid)-8s %(gid)-8s %(size)8s "
"%(month)s %(day)s %(yh)s %(display_name)s\n" %
entry)
return 0
def rm(self, dst):
"""Remove file from archive"""
dst = self._get_real_name(dst)
# deleting with quiet option enabled will output nothing, so we get
# empty string here or None in case of error. Not smart.
if self._call_command('delete', dst=dst) is None:
return 1
return 0
def run(self, dst):
"""Execute file out of archive"""
fdesc, tmp_file = mkstemp()
os.close(fdesc)
result = 0
if self.copyout(dst, tmp_file) != 0:
result = 1
os.chmod(tmp_file, int("700", 8))
try:
result = call([tmp_file])
finally:
try:
os.unlink(tmp_file)
except OSError:
pass
return result
def mkdir(self, dst):
"""Create empty directory in archive"""
return self.copyin(dst)
def copyin(self, dst, src=None):
"""Copy file to the archive or create direcotry inside.
If src is empty, create empty directory with dst name."""
current_dir = os.path.abspath(os.curdir)
tmpdir = mkdtemp()
arch_abspath = os.path.realpath(self._arch)
os.chdir(tmpdir)
if src:
os.makedirs(os.path.dirname(dst))
os.link(src, dst)
else:
os.makedirs(dst)
try:
result = check_call([ARCHIVER, CMDS["write"], arch_abspath, dst])
except CalledProcessError:
return 1
finally:
os.chdir(current_dir)
shutil.rmtree(tmpdir)
return result
def copyout(self, src, dst):
"""Copy file out form archive."""
src = self._get_real_name(src)
fobj = open(dst, "wb")
try:
result = check_call([ARCHIVER, CMDS['read'], self._arch, src],
stdout=fobj)
except CalledProcessError:
return 1
finally:
fobj.close()
return result
CALL_MAP = {'list': lambda a: Archive(a.arch).list(),
'copyin': lambda a: Archive(a.arch).copyin(a.src, a.dst),
'copyout': lambda a: Archive(a.arch).copyout(a.src, a.dst),
'mkdir': lambda a: Archive(a.arch).mkdir(a.dst),
'rm': lambda a: Archive(a.arch).rm(a.dst),
'run': lambda a: Archive(a.arch).run(a.dst)}
def parse_args():
"""Use ArgumentParser to check for script arguments and execute."""
parser = ArgumentParser()
subparsers = parser.add_subparsers(help='supported commands')
parser_list = subparsers.add_parser('list', help="List contents of "
"archive")
parser_copyin = subparsers.add_parser('copyin', help="Copy file into "
"archive")
parser_copyout = subparsers.add_parser('copyout', help="Copy file out of "
"archive")
parser_rm = subparsers.add_parser('rm', help="Delete file in archive")
parser_mkdir = subparsers.add_parser('mkdir', help="Create directory in "
"archive")
parser_run = subparsers.add_parser('run', help="Execute archived file")
parser_list.add_argument('arch', help="archive filename")
parser_list.set_defaults(func=CALL_MAP['list'])
parser_copyin.add_argument('arch', help="archive filename")
parser_copyin.add_argument('src', help="source filename")
parser_copyin.add_argument('dst', help="destination filename (to be "
"written into archive)")
parser_copyin.set_defaults(func=CALL_MAP['copyin'])
parser_copyout.add_argument('arch', help="archive filename")
parser_copyout.add_argument('src', help="source filename (to be read from"
" archive")
parser_copyout.add_argument('dst', help="destination filename")
parser_copyout.set_defaults(func=CALL_MAP['copyout'])
parser_rm.add_argument('arch', help="archive filename")
parser_rm.add_argument('dst', help="File inside archive to be deleted")
parser_rm.set_defaults(func=CALL_MAP['rm'])
parser_mkdir.add_argument('arch', help="archive filename")
parser_mkdir.add_argument('dst', help="Directory name inside archive to "
"be created")
parser_mkdir.set_defaults(func=CALL_MAP['mkdir'])
parser_run.add_argument('arch', help="archive filename")
parser_run.add_argument('dst', help="File to be executed")
parser_run.set_defaults(func=CALL_MAP['run'])
args = parser.parse_args()
return args.func(args)
def no_parse():
"""Failsafe argument "parsing". Note, that it blindly takes positional
arguments without checking them. In case of wrong arguments it will
silently exit"""
try:
if sys.argv[1] not in ('list', 'copyin', 'copyout', 'rm', 'mkdir',
"run"):
sys.exit(2)
except IndexError:
sys.exit(2)
class Arg(object):
"""Mimic argparse object"""
dst = None
src = None
arch = None
arg = Arg()
try:
arg.arch = sys.argv[2]
if sys.argv[1] in ('copyin', 'copyout'):
arg.src = sys.argv[3]
arg.dst = sys.argv[4]
elif sys.argv[1] in ('rm', 'run', 'mkdir'):
arg.dst = sys.argv[3]
except IndexError:
sys.exit(2)
return CALL_MAP[sys.argv[1]](arg)
if __name__ == "__main__":
try:
from argparse import ArgumentParser
PARSE_FUNC = parse_args
except ImportError:
PARSE_FUNC = no_parse
sys.exit(PARSE_FUNC())

144
ulzx Executable file
View File

@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""
Read only, Amiga LZX[1] archiver Virtual filesystem executive for Midnight
Commander.
Tested against python 3.8, unlzx[1] 1.1 and mc 4.8.22
[1] ftp://us.aminet.net/pub/aminet/misc/unix/unlzx.c.gz.readme
Changelog:
1.3 Code cleanup
1.2 Use python3
1.1 Moved common code into extfslib library
1.0 Initial release
Author: Roman 'gryf' Dobosz <gryf73@gmail.com>
Date: 2023-10-20
Version: 1.2
Licence: BSD
"""
import os
import re
import shutil
import subprocess
import sys
import tempfile
import extfslib
class ULzx(extfslib.Archive):
"""LZX archive handle. Provides interface to MC's extfs subsystem"""
LINE_PAT = re.compile(r"^\s+(?P<size>\d+)\s+"
r"((n/a)|\d+)\s"
r"(?P<time>\d{2}:\d{2}:\d{2})\s+"
r"(?P<date>\d+-[a-z]{3}-\d{4})\s"
r"(?P<perms>[h-][s-][p-][a-][r-][w-][e-][d-])\s"
r"\"(?P<fpath>.*)\"")
ARCHIVER = "unlzx"
CMDS = {"list": "-v",
"read": "-x"}
DATETIME = "%02d-%02d-%s %02d:%02d"
def _get_date(self, time, date):
"""Return MM-DD-YYYY hh:mm formatted date out of time and date
strings"""
month_list = ["jan", "fe", "mar", "apr", "may", "jun", "jul",
"aug", "sep", "oct", "nov", "dec"]
day, month, year = date.split("-")
month = month_list.index(month) + 1
hours, minutes, dummy = time.split(":")
return self.DATETIME % (month, int(day), year, int(hours),
int(minutes))
def _map_name(self, name):
if name.startswith(" "):
new_name = "".join(["~", name[1:]])
return new_name
return name
def _get_dir(self):
"""Prepare archive file listing"""
contents = []
out = subprocess.run([self.ARCHIVER, self.CMDS['list'], self._arch],
capture_output=True, encoding="latin-1")
if out.stderr:
sys.stderr.write(out.stderr)
for line in out.stdout.split("\n"):
match = self.LINE_PAT.match(line)
if not match:
continue
match_entry = match.groupdict()
entry = {}
for key in match_entry:
entry[key] = match_entry[key]
del match_entry
entry['datetime'] = self._get_date(entry['time'], entry['date'])
entry['display_name'] = self._map_name(entry['fpath'])
entry['perms'] = "-rw-r--r--" # lzx doesn't store empty dirs
entry['uid'] = str(self._uid)
entry['gid'] = str(self._gid)
contents.append(entry)
return contents
def list(self):
"""Output contents of the archive to stdout"""
for entry in self._contents:
sys.stdout.write(self.ITEM.decode('utf-8') % entry)
return 0
def run(self, dst):
"""Execute file out of archive"""
fdesc, tmp_file = tempfile.mkstemp()
os.close(fdesc)
result = 0
if self.copyout(dst, tmp_file) != 0:
result = 1
os.chmod(tmp_file, int("700", 8))
with open(os.devnull, "w") as fnull:
result = subprocess.run([tmp_file], stderr=fnull)
os.unlink(tmp_file)
return result.returncode
def copyout(self, src, dst):
"""Unfortunately, to copy one file out entire LZX archive have to be
extracted. For small archives is not a problem, but in relatively big
one it could be a performance issue."""
tmp_dir = tempfile.mkdtemp()
src = [e['display_name'] for e in self._contents
if e['display_name'] == src]
if not src:
raise IOError("No such file or directory")
src = src[0].encode('latin-1')
current_dir = os.path.abspath(os.curdir)
os.chdir(tmp_dir)
with open(os.devnull, "w") as fnull:
result = subprocess.run([self.ARCHIVER, self.CMDS['read'],
os.path.join(current_dir, self._arch)],
stdout=fnull, stderr=fnull)
if result.returncode == 0:
# use subprocess, as shutil.copy2 will complain about mixing
# strings with bytes
subprocess.run(['cp', src, dst])
shutil.rmtree(tmp_dir)
os.chdir(current_dir)
return result.returncode
if __name__ == "__main__":
sys.exit(extfslib.parse_args(ULzx))