Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ General Public License v2 (GPLv2). See LICENSES directory or go to
explanations ([#2507](https://github.com/bit-team/backintime/issues/2507))

### Added
- Diagnostics: Detect and report coreutils variant (GNU, Rust/uutils,
BusyBox) in `--diagnostics` output
([#2478](https://github.com/bit-team/backintime/issues/2478))
- Gocryptfs for SSH encrypted profiles
([PR#2486](https://github.com/bit-team/backintime/pull/2486))
- Dependencies:
Expand Down
42 changes: 42 additions & 0 deletions common/diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,9 @@ def collect_diagnostics():
result['external-programs']['shell-version'] \
= shell_version.split('\n')[0]

# Coreutils (GNU, Rust/uutils, BusyBox, or unknown)
result['external-programs']['coreutils'] = _get_coreutils_info()

result = _replace_username_paths(
result=result,
username=pwd.getpwuid(os.getuid()).pw_name
Expand Down Expand Up @@ -348,6 +351,45 @@ def _get_rsync_info():
return info


def _get_coreutils_info():
"""Detect the installed coreutils variant (GNU, Rust/uutils, BusyBox).

Uses ``ls --version`` as a probe because ``ls`` is present on every
Unix-like system and each implementation produces a distinctive
version string.

Returns:
str: A human-readable description of the coreutils variant and
version, or an error string if detection fails.
"""
output = _get_extern_versions(['ls', '--version'])

if output is None or output.startswith('(no ls)'):
return output if output else '(ls detection failed)'

first_line = output.split('\n')[0]

# GNU coreutils: "ls (GNU coreutils) 9.4"
if 'GNU coreutils' in output:
return first_line

# BusyBox: "BusyBox v1.36.1 (date) multi-call binary"
if 'BusyBox' in output:
return first_line

# Rust/uutils coreutils: "ls 0.0.27" (no "GNU coreutils" tag)
# Distinguish from other implementations by checking for the
# uutils project name which appears in the full --version output.
if 'uutils' in output:
return f'Rust/uutils coreutils - {first_line}'

# uutils sometimes prints just the version without the project name
# on older releases; the output is a bare version number with no
# "GNU" or "BusyBox" marker.
# In that ambiguous case, return the first line as-is.
return first_line


def _get_os_release():
"""Try to get the name and version of the operating system used.

Expand Down
46 changes: 45 additions & 1 deletion common/test/test_diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ def test_some_content(self, mock_qt):

# 2nd level "external-programs"
minimal_keys = [
'rsync', 'shell', 'RSYNC_OLD_ARGS', 'RSYNC_PROTECT_ARGS']
'rsync', 'shell', 'RSYNC_OLD_ARGS', 'RSYNC_PROTECT_ARGS',
'coreutils']
for key in minimal_keys:
self.assertIn(key, result['external-programs'], key)

Expand Down Expand Up @@ -111,3 +112,46 @@ def test_replace_user_path(self):
diagnostics._replace_username_paths(d, 'user'),
d
)

@patch('diagnostics._get_extern_versions')
def test_coreutils_gnu(self, mock_extern):
"""Detect GNU coreutils from ls --version output."""
mock_extern.return_value = (
'ls (GNU coreutils) 9.4\n'
'Copyright (C) 2024 Free Software Foundation, Inc.'
)
# pylint: disable=protected-access
result = diagnostics._get_coreutils_info()
self.assertEqual(result, 'ls (GNU coreutils) 9.4')

@patch('diagnostics._get_extern_versions')
def test_coreutils_busybox(self, mock_extern):
"""Detect BusyBox from ls --version output."""
mock_extern.return_value = (
'BusyBox v1.36.1 (2023-11-07 18:53:09 UTC) multi-call binary.'
)
# pylint: disable=protected-access
result = diagnostics._get_coreutils_info()
self.assertEqual(
result,
'BusyBox v1.36.1 (2023-11-07 18:53:09 UTC) multi-call binary.'
)

@patch('diagnostics._get_extern_versions')
def test_coreutils_uutils(self, mock_extern):
"""Detect Rust/uutils coreutils from ls --version output."""
mock_extern.return_value = (
'ls 0.0.27\n'
'uutils coreutils - MIT license'
)
# pylint: disable=protected-access
result = diagnostics._get_coreutils_info()
self.assertEqual(result, 'Rust/uutils coreutils - ls 0.0.27')

@patch('diagnostics._get_extern_versions')
def test_coreutils_not_found(self, mock_extern):
"""Handle missing ls gracefully."""
mock_extern.return_value = '(no ls)'
# pylint: disable=protected-access
result = diagnostics._get_coreutils_info()
self.assertEqual(result, '(no ls)')
Comment on lines +151 to +157

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi Adrian,

I see no value in testing for "not found" but feed the return value. Technically it tests the same as test_coreutils_uutils(). But I might miss something here?

What do you think?