can-utils/tests/test_isobusfs.py

504 lines
17 KiB
Python

# SPDX-License-Identifier: GPL-2.0-only
#
# Copyright (c) 2025 Linux CAN project
#
# 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 2 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.
import pytest
import subprocess
import os
import time
import tempfile
import signal
import sys
import struct
import datetime
import stat
import select
# Helper function to check for binaries
def require_binaries(bin_path, binaries):
for binary in binaries:
if not os.path.exists(os.path.join(bin_path, binary)):
pytest.skip(f"Binary '{binary}' not found in {bin_path}")
def create_test_file(filepath, size_bytes):
"""
Python implementation of isobusfs_create_test_file.sh
Generates a file with a specific XOR pattern (0xdeadbeef).
"""
pattern = 0xdeadbeef
# Each iteration writes 4 bytes
iterations = size_bytes // 4
with open(filepath, 'wb') as f:
# Write in chunks to improve performance for larger files
chunk_size = 4096 # iterations per chunk
for i in range(0, iterations, chunk_size):
chunk_end = min(i + chunk_size, iterations)
data = bytearray()
for counter in range(i, chunk_end):
# bash: printf "%08x" $((counter ^ pattern)) | xxd -r -p
# This results in Big-Endian binary representation
val = counter ^ pattern
data.extend(struct.pack('>I', val))
f.write(data)
def create_test_infrastructure(root):
"""
Python implementation of isobusfs_create_test_dirs.sh
Creates the complex directory structure, special files, and timestamps.
"""
print(f"DEBUG: Generating test infrastructure in {root}")
# 1. Create Directory Hierarchy
# NOTE: Changed MCMC0683 to MCMC0000 to match the server's default fallback
# when no local name is provided via -n argument.
dirs = [
"dir1/dir2/dir3/dir4",
"dir1/dir2/dir3/dir5",
"MCMC0000/msd_dir1/msd_dir2/~/~tilde_dir",
"dir1/~/~",
"dir1/dir2/special_chars_*?/",
"dir1/dir2/unicode_名字",
]
for d in dirs:
os.makedirs(os.path.join(root, d), exist_ok=True)
# 2. Create simple text file
with open(os.path.join(root, "dir1/dir2/file0"), "w") as f:
f.write("hello\n")
# 3. Create binary test files (1KB and 1MB)
create_test_file(os.path.join(root, "dir1/dir2/file1k"), 1024)
create_test_file(os.path.join(root, "dir1/dir2/file1m"), 1048576)
# 4. Create 300 files with long names
# Suffix reconstructed based on visual pattern (alphabet repeated ~8.5 times ending in 'p')
alphabet = "abcdefghijklmnopqrstuvwxyz"
long_suffix = (alphabet * 8) + alphabet[:16] # Results in 224 chars
for count in range(1, 301):
fname = f"long_name_{count}_{long_suffix}"
with open(os.path.join(root, fname), 'w') as f:
pass
# 5. Create special permission files
base_perm_dir = os.path.join(root, "dir1/dir2")
# hidden_file
with open(os.path.join(base_perm_dir, "hidden_file"), 'w') as f:
pass
# readonly_file (chmod 444)
ro_path = os.path.join(base_perm_dir, "readonly_file")
with open(ro_path, 'w') as f:
pass
os.chmod(ro_path, 0o444)
# executable_file (chmod +x -> 755 typically)
exe_path = os.path.join(base_perm_dir, "executable_file")
with open(exe_path, 'w') as f:
pass
current_mode = os.stat(exe_path).st_mode
os.chmod(exe_path, current_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
# no_read_permission_file (chmod 000)
no_read_path = os.path.join(base_perm_dir, "no_read_permission_file")
with open(no_read_path, 'w') as f:
pass
os.chmod(no_read_path, 0o000)
# 6. Date/Timestamp Problems
# Note: timestamps might be limited by the host OS (Y2038 on 32-bit systems)
date_dirs = {
"y2000_problem": "2000-01-01 00:00:00",
"y2038_problem": "2038-01-19 03:14:07",
"y1979_problem": "1979-12-31 23:59:59",
"y1980_problem": "1980-01-01 00:00:00",
"y2107_problem": "2107-12-31 23:59:59",
"y2108_problem": "2108-01-01 00:00:00",
}
for dirname, date_str in date_dirs.items():
dir_path = os.path.join(base_perm_dir, dirname)
os.makedirs(dir_path, exist_ok=True)
try:
dt = datetime.datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S")
ts = dt.timestamp()
# Set access and modification time
os.utime(dir_path, (ts, ts))
except (OverflowError, OSError, ValueError):
print(f"WARNING: Could not set timestamp {date_str} for {dirname} on this platform.")
def test_isobusfs_selftest(bin_path, can_interface):
"""
Test ISOBUS File Server interaction using the client's 'selftest' command.
Includes generation of complex file infrastructure.
Description:
1. Create a temporary directory.
2. Populate it with complex file structure (long names, deep dirs, special permissions).
3. Start isobusfs-srv.
4. Start isobusfs-cli and run 'selftest'.
5. Verify Test 1-7 PASS explicitly.
6. Verify Test 8 PASS or prove successful data transfer (partial pass).
7. If tests fail, run internal 'dmesg' command on client.
Manual Reproduction:
1. Run isobusfs_create_test_dirs.sh inside a test folder.
2. ./isobusfs-srv -i vcan0 -a 80 -v vol1:/path/to/folder -l 3
3. ./isobusfs-cli -i vcan0 -a 90 -r 80 -l 3
4. Type 'selftest'
"""
server_bin = "isobusfs-srv"
client_bin = "isobusfs-cli"
require_binaries(bin_path, [server_bin, client_bin])
server_exe = os.path.join(bin_path, server_bin)
client_exe = os.path.join(bin_path, client_bin)
# Use a temporary directory as the volume root
with tempfile.TemporaryDirectory() as tmp_vol_dir:
# 1. Generate Infrastructure
create_test_infrastructure(tmp_vol_dir)
# Define addresses (hex)
srv_addr = "80"
cli_addr = "90"
vol_name = "vol1"
# Construct Server Command
server_cmd = [
server_exe,
"-i", can_interface,
"-a", srv_addr,
"-v", f"{vol_name}:{tmp_vol_dir}",
"-l", "3"
]
print(f"DEBUG: Starting Server: {' '.join(server_cmd)}")
# Start Server
server_proc = subprocess.Popen(
server_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
client_proc = None
try:
# Wait for server initialization
time.sleep(1)
if server_proc.poll() is not None:
_, err = server_proc.communicate()
pytest.fail(f"isobusfs-srv failed to start. Error: {err.decode('utf-8', errors='replace')}")
# Construct Client Command
client_cmd = [
client_exe,
"-i", can_interface,
"-a", cli_addr,
"-r", srv_addr,
"-l", "3"
]
print(f"DEBUG: Starting Client: {' '.join(client_cmd)}")
# Start Client
client_proc = subprocess.Popen(
client_cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=0
)
# Interact with Client
print("DEBUG: Sending 'selftest' command...")
client_proc.stdin.write("selftest\n")
client_proc.stdin.flush()
# Allow time for tests (20s)
time.sleep(20)
# After tests, request internal dmesg to check for errors
print("DEBUG: Sending 'dmesg' command to client...")
client_proc.stdin.write("dmesg\n")
client_proc.stdin.flush()
# Give dmesg a moment to output
time.sleep(1)
client_proc.terminate()
try:
outs, errs = client_proc.communicate(timeout=1)
except subprocess.TimeoutExpired:
client_proc.kill()
outs, errs = client_proc.communicate()
print("-" * 20 + " Client Output " + "-" * 20)
print(outs)
print("-" * 20 + " Client Errors " + "-" * 20)
print(errs)
print("-" * 50)
# Verification logic
failures = []
# Tests 1-7 MUST pass
mandatory_tests = [
"Test 1: PASSED",
"Test 2: PASSED",
"Test 3: PASSED",
"Test 4: PASSED",
"Test 5: PASSED",
"Test 6: PASSED",
"Test 7: PASSED",
"Test 8: PASSED",
]
for test in mandatory_tests:
if test not in outs:
failures.append(test.replace(": PASSED", ""))
if failures:
pytest.fail(f"The following ISOBUS FS tests failed: {', '.join(failures)}. See output above for details and internal dmesg log.")
finally:
if server_proc.poll() is None:
server_proc.terminate()
try:
server_out, server_err = server_proc.communicate(timeout=1)
except subprocess.TimeoutExpired:
server_proc.kill()
server_out, server_err = server_proc.communicate()
print("-" * 20 + " Server Output " + "-" * 20)
# Manually decode output, replacing invalid characters
if server_out:
print(server_out.decode('utf-8', errors='replace'))
if server_err:
print(server_err.decode('utf-8', errors='replace'))
print("-" * 50)
if client_proc and client_proc.poll() is None:
client_proc.kill()
def test_isobusfs_interactive_commands(bin_path, can_interface):
"""
Test the interactive command shell of isobusfs-cli.
Covers:
- help: Help text display
- ls/ll: Directory listing
- cd: Directory navigation
- pwd: Working directory reporting (ISOBUS path format checks)
- get: File download
- dmesg: Log buffer access (executed at the end)
- exit: Clean exit
- quit: Clean exit (tested in separate session)
"""
server_bin = "isobusfs-srv"
client_bin = "isobusfs-cli"
require_binaries(bin_path, [server_bin, client_bin])
# FIX 1: Use absolute paths to find binaries even when CWD changes
server_exe = os.path.abspath(os.path.join(bin_path, server_bin))
client_exe = os.path.abspath(os.path.join(bin_path, client_bin))
# Helper function for truly interactive communication
def interact(proc, cmd=None, expect_prompt=True, timeout=5):
"""
Sends a command and reads output until the prompt 'isobusfs> ' appears.
Using binary mode (bytes) to avoid buffering issues.
"""
if cmd:
proc.stdin.write(cmd.encode('utf-8') + b"\n")
proc.stdin.flush()
if not expect_prompt:
return ""
output = b""
start_time = time.time()
while time.time() - start_time < timeout:
# Check if stdout has data ready to read
rlist, _, _ = select.select([proc.stdout], [], [], 0.1)
if rlist:
# Read 1 byte at a time to safely detect the prompt boundary
char = proc.stdout.read(1)
if not char: # EOF
break
output += char
if output.endswith(b"isobusfs> "):
return output.decode('utf-8', errors='replace')
# Check if process died
if proc.poll() is not None:
break
# If we timed out but got some output, return it for debugging/assertion
return output.decode('utf-8', errors='replace')
# Use temporary directories
with tempfile.TemporaryDirectory() as srv_dir, tempfile.TemporaryDirectory() as cli_dir:
# 1. Prepare Server Data
create_test_infrastructure(srv_dir)
# 2. Start Server
srv_addr = "80"
cli_addr = "95"
vol_name = "testvol"
server_cmd = [
server_exe,
"-i", can_interface,
"-a", srv_addr,
"-v", f"{vol_name}:{srv_dir}",
"-l", "3"
]
print(f"DEBUG: Starting Server: {' '.join(server_cmd)}")
server_proc = subprocess.Popen(
server_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
client_proc = None
try:
time.sleep(1)
if server_proc.poll() is not None:
_, err = server_proc.communicate()
pytest.fail(f"Server failed to start: {err.decode('utf-8', errors='replace')}")
client_cmd = [
client_exe,
"-i", can_interface,
"-a", cli_addr,
"-r", srv_addr,
"-l", "3"
]
print(f"DEBUG: Running Client Session 1 in {cli_dir}")
# FIX 2: Use bufsize=0 and binary streams for precise control
client_proc = subprocess.Popen(
client_cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
cwd=cli_dir,
bufsize=0
)
# --- Session 1: Main Workflow ---
# 1. Initial Prompt (wait for startup)
out = interact(client_proc)
assert "Interactive mode" in out
# 2. Help
out = interact(client_proc, "help")
assert "exit - exit interactive mode" in out
# 3. Pwd (Root)
# FIX 3: Expect ISOBUS style path: \\<volume_name>
out = interact(client_proc, "pwd")
assert f"\\\\{vol_name}" in out
# 4. ls
out = interact(client_proc, "ls")
assert "dir1" in out
assert "long_name_1_" in out # Verify we still see some long names
# 5. cd dir1
out = interact(client_proc, "cd dir1")
# 6. pwd (check change)
out = interact(client_proc, "pwd")
assert f"\\\\{vol_name}\\dir1" in out
# 7. ll
out = interact(client_proc, "ll")
assert "dir2" in out
# 8. cd dir2
out = interact(client_proc, "cd dir2")
# 9. pwd
out = interact(client_proc, "pwd")
assert f"\\\\{vol_name}\\dir1\\dir2" in out
# 10. ls
out = interact(client_proc, "ls")
assert "file0" in out
# 11. get file0
out = interact(client_proc, "get file0")
assert "File transfer completed" in out
# Verify file content
downloaded_file = os.path.join(cli_dir, "file0")
assert os.path.exists(downloaded_file), "Command 'get file0' failed."
with open(downloaded_file, "r") as f:
assert f.read() == "hello\n"
# 12. Dmesg (Moved to end to prevent buffer output interference)
out = interact(client_proc, "dmesg")
# 13. Exit
client_proc.stdin.write(b"exit\n")
client_proc.stdin.flush()
try:
client_proc.wait(timeout=2)
except subprocess.TimeoutExpired:
client_proc.kill()
pytest.fail("Client did not exit cleanly after 'exit' command")
# FIX 4: Accept 252 (which is -EINTR / -4 cast to unsigned 8-bit)
assert client_proc.returncode in [0, -2, 252]
# --- Session 2: Test 'quit' alias ---
print("DEBUG: Running Client Session 2 to test 'quit'")
client_proc = subprocess.Popen(
client_cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=0
)
# Consume initial prompt
interact(client_proc)
# Send quit
client_proc.stdin.write(b"quit\n")
client_proc.stdin.flush()
client_proc.wait(timeout=2)
assert client_proc.returncode in [0, -2, 252]
finally:
# Cleanup
if server_proc.poll() is None:
server_proc.terminate()
server_proc.wait(timeout=1)
if client_proc and client_proc.poll() is None:
client_proc.kill()