255 lines
8.1 KiB
Python
255 lines
8.1 KiB
Python
# This file is part of Cockpit.
|
|
#
|
|
# Copyright (C) 2022 Red Hat, Inc.
|
|
#
|
|
# This program is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU General Public License as published by
|
|
# the Free Software Foundation, either version 3 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
import logging
|
|
import os
|
|
import random
|
|
|
|
from typing import Dict
|
|
|
|
from systemd_ctypes import PathWatch
|
|
from systemd_ctypes.inotify import Event as InotifyEvent
|
|
|
|
from ..channel import Channel, ChannelError, GeneratorChannel
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def tag_from_stat(buf):
|
|
return f'1:{buf.st_ino}-{buf.st_mtime}'
|
|
|
|
|
|
def tag_from_path(path):
|
|
try:
|
|
return tag_from_stat(os.stat(path))
|
|
except FileNotFoundError:
|
|
return '-'
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
def tag_from_fd(fd):
|
|
try:
|
|
return tag_from_stat(os.fstat(fd))
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
class FsListChannel(Channel):
|
|
payload = 'fslist1'
|
|
|
|
def send_entry(self, event, entry):
|
|
if entry.is_symlink():
|
|
mode = 'link'
|
|
elif entry.is_file():
|
|
mode = 'file'
|
|
elif entry.is_dir():
|
|
mode = 'directory'
|
|
else:
|
|
mode = 'special'
|
|
|
|
self.send_message(event=event, path=entry.name, type=mode)
|
|
|
|
def do_open(self, options):
|
|
path = options.get('path')
|
|
watch = options.get('watch', True)
|
|
|
|
if watch:
|
|
raise ChannelError('not-supported', message='watching is not implemented, use fswatch1')
|
|
|
|
for entry in os.scandir(path):
|
|
self.send_entry("present", entry)
|
|
|
|
if not watch:
|
|
self.done()
|
|
self.close()
|
|
|
|
|
|
class FsReadChannel(GeneratorChannel):
|
|
payload = 'fsread1'
|
|
|
|
def do_yield_data(self, options: Dict[str, object]) -> GeneratorChannel.DataGenerator:
|
|
binary = options.get('binary', False)
|
|
max_read_size = options.get('max_read_size')
|
|
|
|
logger.debug('Opening file "%s" for reading', options['path'])
|
|
|
|
try:
|
|
with open(options['path'], 'rb') as filep:
|
|
buf = os.stat(filep.fileno())
|
|
if max_read_size is not None and buf.st_size > max_read_size:
|
|
raise ChannelError('too-large')
|
|
|
|
while True:
|
|
data = filep.read1(Channel.BLOCK_SIZE)
|
|
if data == b'':
|
|
break
|
|
logger.debug(' ...sending %d bytes', len(data))
|
|
if not binary:
|
|
data = data.replace(b'\0', b'').decode('utf-8', errors='ignore').encode('utf-8')
|
|
yield data
|
|
|
|
return {'tag': tag_from_stat(buf)}
|
|
|
|
except FileNotFoundError:
|
|
return {'tag': '-'}
|
|
except PermissionError:
|
|
raise ChannelError('access-denied')
|
|
except OSError as error:
|
|
raise ChannelError('internal-error', message=str(error)) from error
|
|
|
|
|
|
class FsReplaceChannel(Channel):
|
|
payload = 'fsreplace1'
|
|
|
|
_path = None
|
|
_tag = None
|
|
_tempfile = None
|
|
_temppath = None
|
|
|
|
def unlink_temppath(self):
|
|
try:
|
|
os.unlink(self._temppath)
|
|
except OSError:
|
|
pass # might have been removed from outside
|
|
|
|
def do_open(self, options):
|
|
self._path = options.get('path')
|
|
self._tag = options.get('tag')
|
|
|
|
def do_data(self, data):
|
|
if self._tempfile is None:
|
|
# keep this bounded, in case anything unexpected goes wrong
|
|
for i in range(10):
|
|
suffix = ''.join(random.choices("abcdefghijklmnopqrstuvwxyz0123456789_", k=6))
|
|
self._temppath = f'{self._path}.cockpit-tmp.{suffix}'
|
|
try:
|
|
fd = os.open(self._temppath, os.O_CREAT | os.O_WRONLY | os.O_EXCL, 0o666)
|
|
break
|
|
except FileExistsError:
|
|
continue
|
|
except PermissionError:
|
|
raise ChannelError('access-denied')
|
|
except OSError as ex:
|
|
raise ChannelError('internal-error', message=str(ex))
|
|
else:
|
|
raise ChannelError('internal-error', message=f"Could not find unique file name for replacing {self._path}")
|
|
|
|
try:
|
|
self._tempfile = os.fdopen(fd, 'wb')
|
|
except OSError:
|
|
# Should Not Happen™, but let's be safe and avoid fd leak
|
|
os.close(fd)
|
|
self.unlink_temppath()
|
|
raise
|
|
|
|
self._tempfile.write(data)
|
|
|
|
def do_done(self):
|
|
if self._tempfile is None:
|
|
try:
|
|
os.unlink(self._path)
|
|
# crash on other errors, as they are unexpected
|
|
except FileNotFoundError:
|
|
pass
|
|
else:
|
|
self._tempfile.flush()
|
|
|
|
if self._tag and self._tag != tag_from_path(self._path):
|
|
raise ChannelError('change-conflict')
|
|
|
|
try:
|
|
os.rename(self._temppath, self._path)
|
|
except OSError:
|
|
# ensure to not leave the temp file behind
|
|
self.unlink_temppath()
|
|
raise
|
|
self._tempfile.close()
|
|
self._tempfile = None
|
|
|
|
self.done()
|
|
self.close(tag=tag_from_path(self._path))
|
|
|
|
def do_close(self):
|
|
if self._tempfile is not None:
|
|
self._tempfile.close()
|
|
self.unlink_temppath()
|
|
self._tempfile = None
|
|
|
|
|
|
class FsWatchChannel(Channel):
|
|
payload = 'fswatch1'
|
|
_tag = None
|
|
_path = None
|
|
_watch = None
|
|
|
|
# The C bridge doesn't send the initial event, and the JS calls read()
|
|
# instead to figure out the initial state of the file. If we send the
|
|
# initial state then we cause the event to get delivered twice.
|
|
# Ideally we'll sort that out at some point, but for now, suppress it.
|
|
_active = False
|
|
|
|
@staticmethod
|
|
def mask_to_event_and_type(mask):
|
|
if (InotifyEvent.CREATE or InotifyEvent.MOVED_TO) in mask:
|
|
return 'created', 'directory' if InotifyEvent.ISDIR in mask else 'file'
|
|
elif InotifyEvent.MOVED_FROM in mask or InotifyEvent.DELETE in mask or InotifyEvent.DELETE_SELF in mask:
|
|
return 'deleted', None
|
|
elif InotifyEvent.ATTRIB in mask:
|
|
return 'attribute-changed', None
|
|
elif InotifyEvent.CLOSE_WRITE in mask:
|
|
return 'done-hint', None
|
|
else:
|
|
return 'changed', None
|
|
|
|
def do_inotify_event(self, mask, _cookie, name):
|
|
logger.debug("do_inotify_event(%s): mask %X name %s", self._path, mask, name)
|
|
event, type_ = self.mask_to_event_and_type(mask)
|
|
if name:
|
|
# file inside watched directory changed
|
|
path = os.path.join(self._path, name.decode())
|
|
tag = tag_from_path(path)
|
|
self.send_message(event=event, path=path, tag=tag, type=type_)
|
|
else:
|
|
# the watched path itself changed; filter out duplicate events
|
|
tag = tag_from_path(self._path)
|
|
if tag == self._tag:
|
|
return
|
|
self._tag = tag
|
|
self.send_message(event=event, path=self._path, tag=self._tag, type=type_)
|
|
|
|
def do_identity_changed(self, fd, err):
|
|
logger.debug("do_identity_changed(%s): fd %s, err %s", self._path, str(fd), err)
|
|
self._tag = tag_from_fd(fd) if fd else '-'
|
|
if self._active:
|
|
self.send_message(event='created' if fd else 'deleted', path=self._path, tag=self._tag)
|
|
|
|
def do_open(self, options):
|
|
self._path = options['path']
|
|
self._tag = None
|
|
|
|
self._active = False
|
|
self._watch = PathWatch(self._path, self)
|
|
self._active = True
|
|
|
|
self.ready()
|
|
|
|
def do_close(self):
|
|
self._watch.close()
|
|
self._watch = None
|
|
self.close()
|