From e31fa32d0a0a95060bf942e9ecef3bf8c85d3eb6 Mon Sep 17 00:00:00 2001 From: Mike Huettel Date: Mon, 19 Aug 2024 15:12:22 -0400 Subject: [PATCH] Initial commit for v1.0 of Systemd_Snapshot --- ...t Controls Service Request GEN-120066.txt | 126 ++ README.md | 48 +- README_Systemd_Notes.md | 955 ++++++++++ data/._.gitkeep | Bin 0 -> 176 bytes data/.gitkeep | 0 data/graph_style.json | 636 +++++++ dep_obj_parser_tests.py | 429 +++++ graph_test.py | 77 + lib/._.gitkeep | Bin 0 -> 176 bytes lib/.___pycache__ | Bin 0 -> 163 bytes lib/.gitkeep | 0 .../._dep_obj_parser.cpython-312.pyc | Bin 0 -> 163 bytes .../._sysd_obj_parser.cpython-312.pyc | Bin 0 -> 163 bytes .../._unit_file_lists.cpython-312.pyc | Bin 0 -> 163 bytes lib/__pycache__/colors.cpython-310.pyc | Bin 0 -> 2115 bytes lib/__pycache__/colors.cpython-312.pyc | Bin 0 -> 2255 bytes .../dep_obj_parser.cpython-310.pyc | Bin 0 -> 9628 bytes .../dep_obj_parser.cpython-312.pyc | Bin 0 -> 13406 bytes lib/__pycache__/element.cpython-310.pyc | Bin 0 -> 43630 bytes lib/__pycache__/element.cpython-312.pyc | Bin 0 -> 60879 bytes lib/__pycache__/file_handlers.cpython-310.pyc | Bin 0 -> 4984 bytes lib/__pycache__/file_handlers.cpython-312.pyc | Bin 0 -> 6614 bytes lib/__pycache__/grapher.cpython-310.pyc | Bin 0 -> 8835 bytes lib/__pycache__/grapher.cpython-312.pyc | Bin 0 -> 13156 bytes lib/__pycache__/style.cpython-310.pyc | Bin 0 -> 8027 bytes lib/__pycache__/style.cpython-312.pyc | Bin 0 -> 10393 bytes .../sysd_obj_parser.cpython-310.pyc | Bin 0 -> 16316 bytes .../sysd_obj_parser.cpython-312.pyc | Bin 0 -> 23998 bytes .../systemd_mapping.cpython-310.pyc | Bin 0 -> 14210 bytes .../systemd_mapping.cpython-312.pyc | Bin 0 -> 18742 bytes .../unit_file_lists.cpython-310.pyc | Bin 0 -> 10420 bytes .../unit_file_lists.cpython-312.pyc | Bin 0 -> 10760 bytes lib/colors.py | 109 ++ lib/dep_obj_parser.py | 248 +++ lib/element.py | 1571 +++++++++++++++++ lib/file_handlers.py | 121 ++ lib/grapher.py | 302 ++++ lib/style.py | 237 +++ lib/sysd_obj_parser.py | 440 +++++ lib/systemd_mapping.py | 379 ++++ lib/unit_file_lists.py | 869 +++++++++ sysd_obj_parser_tests.py | 684 +++++++ systemd_snapshot.py | 272 +++ 43 files changed, 7501 insertions(+), 2 deletions(-) create mode 100644 Notification regarding Export Controls Service Request GEN-120066.txt create mode 100644 README_Systemd_Notes.md create mode 100644 data/._.gitkeep create mode 100644 data/.gitkeep create mode 100644 data/graph_style.json create mode 100644 dep_obj_parser_tests.py create mode 100755 graph_test.py create mode 100644 lib/._.gitkeep create mode 100755 lib/.___pycache__ create mode 100644 lib/.gitkeep create mode 100644 lib/__pycache__/._dep_obj_parser.cpython-312.pyc create mode 100644 lib/__pycache__/._sysd_obj_parser.cpython-312.pyc create mode 100644 lib/__pycache__/._unit_file_lists.cpython-312.pyc create mode 100644 lib/__pycache__/colors.cpython-310.pyc create mode 100644 lib/__pycache__/colors.cpython-312.pyc create mode 100644 lib/__pycache__/dep_obj_parser.cpython-310.pyc create mode 100644 lib/__pycache__/dep_obj_parser.cpython-312.pyc create mode 100644 lib/__pycache__/element.cpython-310.pyc create mode 100644 lib/__pycache__/element.cpython-312.pyc create mode 100644 lib/__pycache__/file_handlers.cpython-310.pyc create mode 100644 lib/__pycache__/file_handlers.cpython-312.pyc create mode 100644 lib/__pycache__/grapher.cpython-310.pyc create mode 100644 lib/__pycache__/grapher.cpython-312.pyc create mode 100644 lib/__pycache__/style.cpython-310.pyc create mode 100644 lib/__pycache__/style.cpython-312.pyc create mode 100644 lib/__pycache__/sysd_obj_parser.cpython-310.pyc create mode 100644 lib/__pycache__/sysd_obj_parser.cpython-312.pyc create mode 100644 lib/__pycache__/systemd_mapping.cpython-310.pyc create mode 100644 lib/__pycache__/systemd_mapping.cpython-312.pyc create mode 100644 lib/__pycache__/unit_file_lists.cpython-310.pyc create mode 100644 lib/__pycache__/unit_file_lists.cpython-312.pyc create mode 100644 lib/colors.py create mode 100644 lib/dep_obj_parser.py create mode 100644 lib/element.py create mode 100644 lib/file_handlers.py create mode 100644 lib/grapher.py create mode 100644 lib/style.py create mode 100644 lib/sysd_obj_parser.py create mode 100644 lib/systemd_mapping.py create mode 100644 lib/unit_file_lists.py create mode 100644 sysd_obj_parser_tests.py create mode 100755 systemd_snapshot.py diff --git a/Notification regarding Export Controls Service Request GEN-120066.txt b/Notification regarding Export Controls Service Request GEN-120066.txt new file mode 100644 index 0000000..470ba98 --- /dev/null +++ b/Notification regarding Export Controls Service Request GEN-120066.txt @@ -0,0 +1,126 @@ +From: donotreply@ornl.gov +Sent: Tuesday, April 9, 2024 4:11 PM +To: Rader, Carol D.; Cochran III, Eugene; Leskovjan, Andreana +Cc: Graham, Edward W.; Busch, Timothy A. +Subject: Notification regarding Export Controls Service Request # GEN-120066 + +EXPORT CONTROL DETERMINATION NUMBER: COPY-2024-120066 + +April 9, 2024 + +By submitting the attached Opens Source Copyright information, Export Control understands that +author(s) and sponsor concur with the request and that the copyright materials do not include security +controlled data. The software does not appear to be specifically designed, developed, configured, +adapted, or modified for a military, missile, satellite, or other controlled use listed on the USML. Based +upon the information provided the software does not include encryption, crypto-analytic functions or +support prohibitions under 15 CFR 744. The software does not appear to require additional DOE non- +proliferation review nor does this software require notification to the Department of Commerce due to +encryption or other controls. The software and associated documentation is intended for release as +Open Source Software (OSS). The publication of the software under a OSS license does not require an +export control license. + +Export Control has made the following export control determination on a request to review Open Source +Review Requested - ID: 81951603 – systemd Snapshot Technology/Item Classification +Based on available information provided and/or in discussions with the cited parties, the Open Source +Review Requested - ID: 81951603 -- systemd Snapshot meets the following definition for fundamental +research: + +“Research in science, engineering or mathematics, the results of which ordinarily are published and +shared broadly within the research community, and for which the researchers have not accepted +restrictions on publication for proprietary or national security reasons.” + +Therefore, the research output does not fall under export control jurisdiction and is releasable without +restriction. + +Open Source Copyright Submission Information: +Problem that was solved: +More embedded systems are using Linux as their operating system foundation. In some cases, the more +modern systemd initialization system is being used. Systemd comes with many tools to understand the +many files that define how the system's services start; however, the system must be RUNNING in order +to use them since many of the artifacts are maintained in memory. When security analysis is performed +on an embedded system, the analyst may not be able to operate the system firmware, or if the system +can be run, the aforementioned systemd tools are not readily available. These analysis constraints place +the burden of discerning how the firmware initializes, which parameters critical services use, and the +order in which services are started on the analyst without purpose-built tools for the job. Our systemd +snapshot capability seeks to mitigate these barriers to analysis by enabling an analyst to collect, +understand and consolidate systemd startup details in a more efficient manner when only the firmware +image is available and it cannot be easily run. Having such information makes performing security +assessments on embedded systems easier. + +Solution provided by the computer code: +Systemd Snapshot crawls a Linux filesystem according to the systemd specification since parsing order is +important. It parses the many files that define a system's startup semantics and consolidates them into +data structures for manipulation and summarization into other forms. The key point is the system itself +does not need to start, the file system remains a static image. So, we perform analysis on a machine of +our choosing instead of relying on performing analysis using the same system that is relying on systemd +to initialize. Systemd snapshot also seeks to understand which information actually defines system +initialization and which information is not used (e.g., some information may be overridden and some +may just be mistakenly ignored) -- lets call these initialization semantics. In other words, the tool aims to +aggregate data and re-create the information that resides in memory on a running system. An additional +feature that we provide is automatic collection of binary library dependencies and collection and +association of human-readable strings. This information is not necessary for a RUNNING system, but is +extremely valuable for system analysis and security assessment. We provide summarization in file +formats and translation into a graph that can be visualized in third-party tools. Additionally, this +capability can perform targeted analysis on specific subsequences of the initialization process. Since +these systems can be extremely complex and incorporate MANY services that work together, it is +sometimes important to focus the analysis on a particular subset of services a system requires while +ignoring others. Finally, this capability can modify a systemd initialization system in order to better +facilitate emulation of a system when the hardware is not available and more targeted introspection is +necessary. + +Computer code’s functionality: +- aggregate all data from systemd initialization system +- compare data and startups between two systemd systems or between versions of the same system +- obtain file forensics information on binaries called during startup +- provide explicit mapping of implicit dependencies of systemd unit files +- show startup relationships between unit files visually through a graph or via JSON output +- perform targeted analysis on subsets of unit file dependencies +- modify systemd initialization system to better facilitate emulation of a system + +Advantages / Benefits: +Based on our research no tools exist that perform this capability WITHOUT actually running the firmware +that makes use of Systemd. The capability can make assessment of a system much easier since it +consolidates the information contained in many files into a single location. In short, we consolidate and +associate Linux system elements and repackage them in more understandable forms. + + +Regulatory Requirements +This determination is issued in accordance with the Department of Commerce (DOC) under 15 CFR Parts +730-774, or Department of State (DOS) under 22 CFR Parts 120-130, or Nuclear Regulatory Commission +(NRC) under 10 CFR Part 110, or Department of Energy (DOE) 10 CFR Part 810, along with other +applicable laws and regulations related to export control. + +For additional information and references please go to the Export Control Web Page and the Export +Control SBMS Web Page at the following URLs: + +SBMS Information Protection Site: +https://gcc02.safelinks.protection.outlook.com/?url=https%3A%2F%2Fsbms.ornl.gov%2Fsbms%2Fsbmse +arch%2Fsubjarea%2FInfoProtect%2Fsa.cfm&data=05%7C02%7Cgrahamew%40ornl.gov%7Ca1b12d86ff1f +45cc74c708dc58d134a3%7Cdb3dbd434c4b45449f8a0553f9f5f25e%7C1%7C0%7C638482902808507766 +%7CUnknown%7CTWFpbGZsb3d8eyJWIjoiMC4wLjAwMDAiLCJQIjoiV2luMzIiLCJBTiI6Ik1haWwiLCJXVCI6 +Mn0%3D%7C0%7C%7C%7C&sdata=iMZiYPkO%2FasuYjtYBN756tRyE%2FVP6gd2%2BzrjleX34Qs%3D&res +erved=0 +ORNL SBMS Export Control Subject Area - +https://gcc02.safelinks.protection.outlook.com/?url=https%3A%2F%2Fsbms.ornl.gov%2Fsbms%2Fsbmse +arch%2Fsubjarea%2Fexport%2Fpro1.cfm&data=05%7C02%7Cgrahamew%40ornl.gov%7Ca1b12d86ff1f4 +5cc74c708dc58d134a3%7Cdb3dbd434c4b45449f8a0553f9f5f25e%7C1%7C0%7C638482902808517052 +%7CUnknown%7CTWFpbGZsb3d8eyJWIjoiMC4wLjAwMDAiLCJQIjoiV2luMzIiLCJBTiI6Ik1haWwiLCJXVCI6 +Mn0%3D%7C0%7C%7C%7C&sdata=LJhB7b8YODvDoIHfkr4grTciSEbPnrFXoJy2faJ60QY%3D&reserved=0 +ORNL Export Control Web Page – +https://gcc02.safelinks.protection.outlook.com/?url=https%3A%2F%2Fornl.sharepoint.com%2Fsites%2F +ornl%2Fec%2FPages%2FExportControl.aspx&data=05%7C02%7Cgrahamew%40ornl.gov%7Ca1b12d86ff1 +f45cc74c708dc58d134a3%7Cdb3dbd434c4b45449f8a0553f9f5f25e%7C1%7C0%7C63848290280852200 +8%7CUnknown%7CTWFpbGZsb3d8eyJWIjoiMC4wLjAwMDAiLCJQIjoiV2luMzIiLCJBTiI6Ik1haWwiLCJXVCI6 +Mn0%3D%7C0%7C%7C%7C&sdata=I3kCHQ8oE5N6twY37NbfUfclAlneVy0h54vEOEN4t1Y%3D&reserved= +0 + +If there are any questions associated with this export control determination please contact the Export +Control Department for clarification and/or guidance. + + +Click here to view this request in the Export Controls Portal: +https://gcc02.safelinks.protection.outlook.com/?url=https%3A%2F%2Fecportal.ornl.gov%2Frequest%2Fi +d%2F120066&data=05%7C02%7Cgrahamew%40ornl.gov%7Ca1b12d86ff1f45cc74c708dc58d134a3%7Cd +b3dbd434c4b45449f8a0553f9f5f25e%7C1%7C0%7C638482902808526472%7CUnknown%7CTWFpbGZs +b3d8eyJWIjoiMC4wLjAwMDAiLCJQIjoiV2luMzIiLCJBTiI6Ik1haWwiLCJXVCI6Mn0%3D%7C0%7C%7C%7C&s +data=9tR9VEK1j7WuvhQS%2Fis9Z5%2BDt4xD%2F2eTgQm41kgB%2FHk%3D&reserved=0 diff --git a/README.md b/README.md index dbec559..a0150da 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,46 @@ -# Systemd_Snapshot -A tool for helping analysts during forensic analysis of a systemd system +# Systemd Snapshot + +### Cytrics +--- +Why? There are four areas that this tool aids: + +- Rehosting + - Aid identifying what could be "cut out" of a system to focus emulating targeted system behavior. Decrease overall complexity of rehosting. + - When a service doesn't start, this tool may help identify dependencies whose failure to cause the failure of the target service. +- System Evolution + - How does a new firmware version different from a previous version. A top-level view may be discernable by taking a diff of the systemd architecture. +- SBOMs + - This capability identifies both components used (and not used) by a system, but also their dependencies. This is additional information that is not captured in current SBOMs easily. +- Weakness and Vulnerability Identification + - (maybe) bug propagation : if there is a bug in one component, what else could be effected. + - (maybe) identify which components communicate with one another. + +Our initial goal with this capability was for rehosting. + +### IT Admins / Defensive Cybersecurity +--- +- Forensic investigations for linux systems + - Take a snapshot of all the startup services that are started on the default runlevel + - View startup services that WOULD be started if the system were to boot into another runlevel without having to modify the system + - Compare current startup services with a baseline snapshot. You can use a golden image to create the baseline if you are interested in a system currently in production! +- System Hardening + - Investigate and clean up unused or unwanted services with the dependency map (dm.json) + - See exactly what commands your startup services are all running with the master struct (ms.json) + - See which config files are actually being referenced by startup services with the master struct (ms.json) +- Visualize anything listed above with Systemd Snapshot's graphing capability and cytoscape! (graph.json?) + +### Vulnerability Research / Offensive Cybersecurity +--- +- System enumeration + - No installation necessary! Just move scripts to target system and as long as python 3 is installed you can create a master struct (ms.json) + - Enumerate startup services without touching systemctl + - Enumerate POTENTIAL startup service that aren't yet enabled or started +- Vulnerability research + - See above for potential implications + - Investigate configuration weaknesses or vulnerable startup service commands with the master struct (ms.json) + +# Status + +# Installation + +# Use diff --git a/README_Systemd_Notes.md b/README_Systemd_Notes.md new file mode 100644 index 0000000..b9ea334 --- /dev/null +++ b/README_Systemd_Notes.md @@ -0,0 +1,955 @@ +# Systemd Notes + +- systemd is a system and service manager for Linux operating systems. +- When run as the first process on boot (as PID 1), it acts as init system that brings up and maintains userspace services. +- systemd is usually installed as the `/sbin/init` symlink and started during early boot. +- systemd provides a dependency system between entities, called "units," that have 11 different types. +- Units encapsulate various objects (e.g., executables, configuration files, ...) that are relevant for system boot-up and maintenance. + +## Units +- Plain Unit: not a template or instance. +- Template Unit: a unit that can be instantiated into a concrete or instance unit; many units can be created and customized from a template. +- Instance Unit: created from a template + +## Systemd Information +- The best information is probably in the man files for systemd +- bootup(7) +- boot(7) +- systemctl(1) +- journalctl(1) +- systemd-notify(1) +- systemd-cgls(1) +- systemd-analyze(1) +- systemd.generator(7) +- systemd.boot(7) +- systemd(1) +- systemd.unit(5): + - lists default search directories (system and user) +- systemd.syntax(7) +- systemd.directives(7) +- systemd.target(5) +- systemd.device(5) +- systemd.mount(5) +- systemd.automount(5) +- systemd.timer(5) +- systemd.time(7) +- systemd.swap(5) +- systemd.path(5) +- systemd.slice(5) +- systemd.scope(5) +- systemd.special(7) +- systemd.service(5) +- systemd.socket(5) +- systemd-halt.service(8) +- dracut(8) +- user@.service(5) +- systemd-system.conf(5) +- systemd-user.conf(5) +- systemd.unit(5) + +## Unit File Load Paths (systemd.unit(5)) +- Unit files are loaded from a set of paths determined during compilation (kernel comp?) + - See the man pages above for the list. +- Unit files found first OVERRIDE files with the same name found LATER. + +## Boot +- On first boot, systemd will enable or disable units according to a preset policy. + - See systemd.preset(5) and "First Boot Semantics" in machine-id(5). +- systemd contains NATIVE IMPLEMENTATIONS of various tasks that need to be executed as part of the boot process. For example, it sets the hostname or configures the loopback network device. It also sets up and mounts various API file systems, such as /sys/ or /proc/. + - JMC: Does this mean it does these things as part of the systemd binary implementation and NOT due to a unit file? + +### Unit Creation +- Most units are configured in unit configuration files. +- Some are created automatically from other configuration files, +- Some are dynamically created from system state, and +- Some are created programmatically at runtime. + +NOTE: based on the above, a STATIC ANALYSIS may NOT be sufficient to fully understand system setup. + +### Unit Loading +- systemd attempts to keep a minimal set of units loaded into memory (read from disk) +- systemd will automatically and implicitly load units (read unit files) from disk into memory when they are referenced. Referencing occurs when: + 1. A loaded unit references it with a dependency, e.g., After= + 2. The unit is starting, running, reloading, or stopping. + 3. The unit is in the FAILED state. + 4. A job for the unit is PENDING. + 5. The unit is pinned by an active IPC client program. + 6. The unit is a special PERPETUAL unit. + 7. The unit has running processes associated with it. + +- If a unit is in memory AND NONE of the above conditions apply, then it and its accounting data is removed from memory (freed). This is called GARBAGE COLLECTION. +- When a unit is garbage collected, all configuration, state, execution results are lost (exception is what is in a log file) + +### Unit States +Units are in one of several GENERAL STATES; these states describe the active or dynamic status of units and the processes they create: +- "active": meaning started, bound, plugged in, ..., depending on the unit type, see below, +- "inactive": meaning stopped, unbound, unplugged, ..., +- "activating": in the process of being activated, i.e. between the two states +- "deactivating" : in the process of being deactivated +- "failed": similar to "inactive" but signifies the service failed in some way. + +- NOTE: unit types may have a number of additional substates, which are mapped to the five generalized unit states described here. +- NOTE: If a unit file is empty OR it is symlinked to /dev/null no configuration will be loaded and it will be given a state of MASKED (cannot be activiated). Use this to DISABLE services. + +## Specific Unit Types +- `.service`: start and control daemons. +- `.scope`: similar to service units, but manage foreign processes instead of starting them as well. See systemd.scope(5). +- `.socket`: encapsulate local IPC or network sockets in the system. + - For each .socket unit, a matching service unit must exist that will start when traffic comes in on the socket. + - Names of .service is the same as the socket by default. + - Service= option in .socket unit changes the default name to something different. +- `.target`: group units, or provide well-known synchronization points during boot-up, see systemd.target(5). +- `.device`: expose kernel devices in systemd and may be used to implement device-based activation. For details, see systemd.device(5). +- `.mount`: control mount points in the file system, for details see systemd.mount(5). +- `.swap`: similar to mount units and encapsulate memory swap partitions or files of the operating system. They are described in systemd.swap(5). +- `.automount`: provide on-demand mounting of file systems as well as parallelized boot-up. See systemd.automount(5). +- `.timer`: trigger activation of other units based on timers. You may find details in systemd.timer(5). +- `.path`: used to activate other services when file system objects change or are modified. See systemd.path(5). + - Paths can be monitored by systemd, this unit file directs what is done when this happens. + - For each *.path unit, a matching *.service file must exist that describes the unit that activates. + - The default name are equivalent, to change this use the `Unit=` directive in the Path file. +- `.slice`: used to group units which manage system processes (such as service and scope units) in a hierarchical tree for resource management purposes. See systemd.slice(5). + +### Unit File Naming +- unit files are named as follows: + +``` +[].` +``` + +- unit name prefix: a string. + - may be parameterized using a instance name in which case it is constructed using a template. + +- A template specifier (unit file name) is translated into ONE or MORE concrete unit files. + - A template is designated by an `@` character with NOTHING between it and the `.` character. + - A template instance is designated by an `@` character and some non-empty string after it and before the `.` character. + +- a unit type suffix: one of the following strings that determines unit type. + - ".service", + - ".socket", + - ".device", + - ".mount", + - ".automount", + - ".swap", + - ".target", + - ".path", + - ".timer", + - ".slice", + - ".scope". + +### Unit Aliases (systemd.unit(5)) +- Unit aliases are defined by creating a symlink having an alias name that points to the target unit name. + - Aliases are used as an alternative name to specify the exact unit that gets loaded. +- Alias names (symlinks) are expected to be in one of the unit search paths. +- Alias name suffix (the type. e.g., .service, .socket) MUST MATCH the unit file they link to. +- Linked unit files DO NOT have to be in the unit search paths; this is DIFFERENT from alias names. + - Example: + - `default.target` is used to designate how to default boot the system. + - `default.target` is a symlink (alias) to either `multi-user.target` or `graphical.target` to SELECT which system startup process should be followed. +- Alias names may be used in commands like disable, start, stop, status, and similar +- Alias names may also be used in all unit dependency directives, including Wants=, Requires=, Before=, After=. +- Alias names CANNOT be used with the preset command. +- Unit files MAY specify their aliases using the `Alias=` directive in the `[Install]` section of the unit file. + - This provides a way to see all the symlinks that COULD EXIST and point to THIS unit. + - The the `Alias=` directive is used two things will happen: + - When the unit is ENABLED (by the systemd tool), symlinks WILL BE DYNAMICALLY CREATED for those names. + - When the unit is DISABLED the symlinks WILL BE REMOVED. +- A plain unit may only be aliased by a plain name (not a template or template instance) +- A template instance may only be aliased by another template instance ( must have a string between @ and .) +- A template may be aliased to another template -- alias will apply to all linked templates. + +Dependencies: + - Implicit: see respective manpages for which dependencies are established for certain services, etc. + - Explicit: can be turned on and off by setting DefaultDependencies= to yes (the default) and no, while implicit dependencies are always in effect. + +### Templates +- A mechanism to create multiple unit files from a single file (the template). +- Literal or full unit file names will always be the target of the initial search. +- Template FILES and directories with a specific naming convention: + - `@.` : template file that defines MULTIPLE SERVICES or UNITS + - Notice the `@.` the characters between the gap between these two characters completes the name of the INSTANCE for that template when it is created. +- Template INSTANCES have a specific name when they are INSTANTIATED. + - `@.` : instantiated unit template + - We would specify that we want a service, etc., of this type and it would be created using the Template that lives on the file system. +- Example: If the system searches for `name@xxx.service` and it CANNOT be found, `name@.service` will become the target. We start searching for a TEMPLATE as opposed to a TEMPLATE INSTANCE. If that is found, the target `name@xxx.service` will be created with the configuration found in `name@.service`. +- In configuration files, use `%i` to identify the `xxx` above in configuration files that live in `name@.service` + +### Dropins (see systemd.unit(5)) +- A unit file (e.g., foo.service) MAY BE paired with a "dropin" directory, e.g., foo.service.d + - xxx.yyy.d +- All files with the suffix `.conf` from this directory will be merged in alphabetical order and added after parsing the main unit file. + - The directives in the `.conf` files are MERGED with the directives in the main unit file and other `.conf` files. +- These dropin `.conf` files provide a simple way to augment the original unit file without explicitly modifying it. +- For units with aliases, all `.conf` files in all dropin alias directories are also parsed. +- For top-level unit types, e.g., service or socket, dropins with directory `.d/` are supported. + - The `.conf` files in this directory, altering or add to the settings of ALL CORRESPONDING UNIT FILES ON THE SYSTEM OF THAT TYPE. + - The `.d/` files have lower precedence compared to files in name-specific override directories. +- In addition to `/etc/systemd/system`, the dropin ".d/" directories for system services can be placed in + /usr/lib/systemd/system or /run/systemd/system directories. +- Precedence Rules: + - Precedence ( 1, ... n ): ( /etc/, /run/, /lib/, UNIT FILE ) + - `/etc/` take precedence over those in `/run/` + - `/run/` take precedence over those in `/usr/lib/`. + - Drop-in files under any of these directories take precedence over unit files wherever located. + - Multiple dropin files with different names are applied in lexicographic order. + +### Slice Units +- A group of processes. + - Units that manage processes (scope and service units) may be assigned to a SPECIFIC SLICE. + - For each slice, resource limits may be set. They apply to all units that are in the slice. +- Hierarchical managed. + - Create a node in the Linux Control Group (cgroup) tree. + - Organized in a tree structure. + - NAME OF SLICE encodes location in TREE. + - NAME has a dash-separated names which describe the path to the slice from the root slice. + - The ROOT SLICE is named: -.slice + - foo.bar.slice : ROOT (-.slice) -> foo.slice -> bar.slice (leaf) +- Cannot be templated. +- Cannot add multiple names to slice units via symlinks. +- Default systemd behavior: + - service and scope units are placed in system.slice. + - virtual machines and containers are in machine.slice + - user sessions are in user.slice + +### Dependencies +- systemd uses DEPENDENCY DIRECTIVES to establish the dependency relationships between units +- systemd uses ORDERING and REQUIREMENT dependencies; they are orthogonal (different purposes) +- USUALLY requirement AND ordering dependencies are placed between two units. +- The MAJORITY of dependencies are implicitly (we don't use the above terms...) created and maintained by systemd. + +#### Implicit Dependencies +- STANDARD DEPENDENCIES that are understood and DO NOT need to be explicitly stated. +- CANNOT BE turned off. + +#### Default Dependencies +- The set of dependencies that are established AUTOMATICALLY when the `DefaultDependencies={no,yes}` directive is YES/TRUE. + - REMEMBER when this directive IS NOT specified it defaults to YES/TRUE. +- See the specific documentation for the unit type, e.g., `system.service(5)` +- Otherwise similar to Implicit Dependencies. + +##### Defaults: Service +- (implicit) Requires= and After= on `dbus.socket` for services of Type=dbus +- (implicit) Socket activated services ordered AFTER their `.socket` units by adding After= dependency. +- (implicit) Services having Sockets= pull in all in that list automatically with Wants= and After= dependencies. +- (default) sysinit.target becomes a Requires= and After= dependency. +- (default) basic.target becomes an After= dependency. +- (default) shutdown.target becomes a Conflicts= and Before= dependency. +- See docs on special case for instanced template units. + +##### Defaults and Implicits: Socket +- (implicit) Before= dependency on the service unit they activate. +- (implicit) Requires= and After= dependencies on all mounts necessary to access file system paths for socket units referencing those paths. +- (implicit) BindsTo= and After= dependencies on the device unit encapsulating the network interface if the socket unit uses BindToDevice= +- (default) Before= dependency on `socket.target` +- (default) After= and Requires= dependency on `sysinit.target` +- (default) Before= and Conflicts= dependency on `shutdown.target` + +##### Defaults and Implicits: Device +- None. + +##### Defaults and Implicits: Mount +- (implicit) If a mount unit is BENEATH another mount unit a requirement and ordering dependency is created. +- (implicit) BindsTo= and After= dependencies on the device unit encapsulating the block device. +- (default) Before= and Conflicts= dependencies on `umount.target` +- (default) After= dependency on `local-fs-pre.target` if mount unit refers to local file system +- (default) Before= dependency on `local-fs.target` if mount unit refers to local file system UNLESS nofail set on mount +- (default) After= dependency on `remote-fs-pre.target, network.target, network-online.target` if a network mount unit. +- (default) Before= dependency on `remote-fs.target` if a network unit unless nofail set. + +##### Defaults and Implicits: Automount +- (implicit) If an automount unit is BENEATH another mount unit a requirement and ordering dependency is created. +- (implicit) Before= dependency created between automount and mount unit it activates. +- (default) Before= and Conflicts= on `umount.target` +- (default) After= on `local-fs-pre.target` +- (default) Before= on `local-fs.target` + +##### Defaults and Implicits: Swap +- (implicit) BindsTo= and After= dependencies on the device and mount units they are activated FROM. +- (default) Conflicts= and Before= on `umount.target` +- (default) Before=swap.target + +##### Defaults and Implicits: Target +- (default) After= dependencies for all configured Wants= and Requires= (unless it wants or requires ITSELF) +- (default) Conflicts= and Before= dependencies on `shutdown.target` + +##### Defaults and Implicits: Path +- (implicit) If a path unit is BENEATH another mount unit a requirement and ordering dependency is created for both. +- (implicit) Before= dependency added between path unit and the unit it is supposed to activate. +- (default) Before= on `paths.target` +- (default) After= and Requires= on `sysinit.target` +- (default) Conflicts= and Before= on `shutdown.target` + +##### Defaults and Implicits: Timer +- (implicit) Before= dependency on the service they activate. +- (default) Requires= and After= on `sysinit.target` +- (default) Before= on `timers.target` +- (default) Conflicts= and Before= on `shutdown.target` +- (default) With at least on OnCalendar= directive, After=time-set.target time-sync.target + +##### Defaults and Implicits: Slice +- (implicit) After= and Requires= on their immediate parent slice unit. +- (default) Conflicts= and Before= on `shutdown.target` + +##### Defaults and Implicits: Scope +- (implicit) See systemd.resource-control(5) +- (default) Conflicts= and Before= on shutdown.target + +### Requirement Dependencies +- These dependencies DO NOT establish an ORDER! +- Wants, Requires, Conflicts +- systemd uses positive requirement dependencies (i.e., Requires=) signifying this unit REQUIRES the units in the list. +- systemd uses negative requirement dependencies (i.e., Conflicts=) signifying this unit CONFLICTS (or cannot run or be used) with the units in the list. +- If only a requirement dependency exists between two units (e.g. foo.service requires bar.service) and both are requested to start, they will be started in parallel. +- In both cases (wants and requires) the PREFERRED WAY to install these links is to use the [Install] section of that target unit file, systemctl enable (or disable to remove them). + +## Unit File Directives + +### Wants +- A REQUIREMENT dependency +- When THIS UNIT is started, all units listed in the WANTS list will ATTEMPT TO be started immediately and in parallel. +- THIS UNIT file (e.g., foo.service) MAY BE paired with a "wants" directory: foo.service.wants + - All units linked in the example foo.service.wants/ directory will be treated as if they were in the WANTS list in the unit file. +- If ANY of the units in the WANTS list fails to start, then THIS UNIT will still start and its validity will not be effected. + +- Unit --> Unit.wants ----> { all links here } --- for each link --> Unit file + +### Requires +- A REQUIREMENT dependency +- Similar to WANTS but with a stronger dependency. + - All `requires` units SHOULD be activiated. + - If one in the list fails an ordering AFTER= dependency is set on the failing unit (THIS UNIT cannot start until AFTER the failed unit starts) and THIS UNIT WILL NOT START. + - If one of the requires list unit is STOPPED, THIS UNIT WILL BE EXPLICITLY STOPPED. +- Requires DOES NOT IMPLY all the units in THIS UNIT's list always have to be active when this unit is running. +- As with wants directories, requires directories can contain links and they are treated as if they are in the REQUIRES list. + +### Requisite +- A REQUIREMENT dependency +- If the units in the Requisite= list are not ALREADY STARTED, they WILL NOT be started AND this unit will NOT BE STARTED AND FAIL. +- SHOULD be combined with ordering dependency After= to ensure THIS UNIT is not started BEFORE the unit in the Requisite= list. +- Requisite=B in A and RequisiteOf=A in B specify opposite ends of this REQUIREMENT + +### BindsTo +- A REQUIREMENT dependency +- Similar to REQUIRES, but this is stronger. +- If the BindsTo unit STOPS (or enters the INACTIVE state), then THIS UNIT STOPS. +- When combined with After= this becomes stronger yet: the bound to unit must be in the ACTIVE state for THIS UNIT to be in the ACTIVE state. +- BindsTo=B in A and BoundBy=A in B specify opposite ends of this REQUIREMENT + +### PartOf +- Similar to REQUIRES, but limited to Starting and Stopping of Units +- When units IN the PartOf= list are STOPPED or RESTARTED that action is propagated to THIS UNIT +- A ONE WAY dependency; things that happen to THIS UNIT do not propagate to the units in the PartOf= list. +- PartOf=B in A and ConsistsOf=A in B specify opposite ends of this REQUIREMENT; ConsistsOf= cannot exist by itself. + +### Upholds +- Similar to WANTS. +- When THIS UNIT is up (active?), all units in the Upholds list are started when found to be inactive or failed AND no job is queued for them. +- Has a CONTINUOUS effect; this dependency will always act when the condition holds (it is not a one-time thing) +- Upholds=B in A and UpheldBy=A in B specify opposite ends of this REQUIREMENT; UpheldBy= cannot exist by itself. + +### Conflicts= +- A NEGATIVE REQUIREMENTS dependency. +- Starting THIS UNIT will STOP the units in the Conflicts= list. +- Starting the units in the Conflicts= list will STOP THIS UNIT. +- Does NOT imply ordering. +- After= or Before= ordering when applied ensures a conflicting unit is STOPPED before the other unit is started. + +### Ordering Dependencies +- After, Before +- systemd uses ordering dependencies: (i.e., After= and Before=). +- After dependencies indicate this UNIT should be started AFTER the UNITS in the After= list. +- Before dependencies indicate this UNIT should be started BEFORE the UNITS in the Before= list. +- Programs and Units may "request" state changes; requests are encapsulated in JOBS and maintained in a queue. +- Ordering dependencies establish when JOBS will be scheduled. + - JOB: + - A JOB may succeed or fail. + + On boot systemd activates the target unit `default.target` whose job is to activate on-boot services and other on-boot units by pulling them in via dependencies. + USUALLY, the unit name is just an ALIAS (symlink) for either `graphical.target` or `multi-user.target`. See systemd.special(7) for details about these target units. + +#### Before +- An ORDERING dependency. +- In foo.service with Before=bar.service, bar.service's start is DELAYED until foo.service has finished starting. + - foo.service ---> bar.service [ foo before bar, or bar after foo ] +- When two units with a Before= dependency are shutdown, the inverse of the start-up order is applied +- Before= dependencies on device units have NO EFFECT (not supported) + +#### After +- An ORDERING dependency +- The LOGICAL INVERSE of Before= +- In foo.service with After=bar.service, when bar.service has finished starting, foo.service ,may start. + - bar.service ---> foo.service [ bar before foo, or foo after bar ] +- When shutdown the inverse occurs, foo.service ---> bar.service [ foo shutodown, then bar ] +- With ANY ordering dependency (Before= or After=) if one unit is SHUTDOWN, the other is STARTED (SHUTDOWN before STARTED) +- IF no ordering dependency, units can be shutdown or started simultaneously. + +### OnFailure +- A list of units that are activated with THIS UNIT enters the FAILED state. + +### OnSuccess +- A list of units that are activated when this unit enters the INACTIVE state. + +### PropagatesReloadTo, ReloadPropagatedFrom +- When a reload request is issued to THIS UNIT, all units in the `PropagatesReloadTo=` list will also be queued to reload. +- When a reload request is issued from a unit containing this unit in its `ReloadPropagatedFrom=` THIS UNIT will be reloaded. + +### PropagatesStopTo, StopPropagatedFrom +- When a stop request is issued to THIS UNIT, all units in the `PropagatesStopTo=` list will also be queued to stop. +- When a stop request is issued from a unit containing this unit in its `StopPropagatedFrom=` THIS UNIT will be stopped. + +### JoinsNamespaceOf +- Used for units that start processes (service units). +- Lists one or more other units whose network or temporary file namespace to join. + +### RequiresMountsFor +- A list of absolute paths. +- Using this list dependencies of `Requires=` and `After=` are automatically added as dependencies for all mount points required to access the specified paths. + +### OnFailureJobMode +- Specifies how the units listed in the `OnFailure=` directive will be enqueued. + +### IgnoreOnIsolate +- See documentation + +### StopWhenUnneeded +- When TRUE, this unit will be stopped when it is no longer USED. +- This unit will automatically be cleaned up when NO OTHER UNIT REQUIRES it. + +### RefuseManualStart, RefuseManualStop +- This unit can only be activated or deactivated INDIRECTLY and NOT thru the command line or other explicit methods. + +### AllowIsolate +- See documentation. + +### DefaultDependencies +- A boolean value +- If yes/true, default dependencies will be CREATED; what is created depends on the unit type. +- It is recommended that this be set to the default or TRUE for almost all units. + +### CollectMode +- Relates to how this unit is garbage collected. + +### FailureAction, SuccessAction +- The action to take when a UNIT STOPS or enters the FAILED or INACTIVE STATES. + +#### FailureActionExitStatus, SuccessActionExitStatus +- Used in conjunction with FailureAction and SuccessAction + +### JobTimeoutSec, JobRunningTimeoutSec +- Time limitations on Jobs + +#### JobTimeoutAction, JobTimeoutRebootArgument +- Used in conjunction with JobTimeoutSec, etc. + +### StartLimitIntervalSec, StartLimitBurst +- Rate limits unit starting + +#### StartLimitAction +- Used with StartLimitIntervalSec, etc. + +### RebootArgument + +### SourcePath +- The path to the configuration file from which this unit file was generated; used for generator tools and not used in normal units. + +## Groups +- Processes (executables) systemd spawns are placed in individual Linux control groups named after the unit to which they belong. This forms a private systemd hierarchy. + - The systemd hierarchy is used to keep track of processes. + - Control group information is maintained in the kernel. + - Control group information is accessible via the file system hierarchy (sysfs beneath /sys/fs/cgroup/). + - Control group information can also be accessed using tools such as systemd-cgls(1) or ps(1). + - `$ ps xawf -eo pid,user,cgroup,args` + - Useful to list all processes and the systemd units they belong to. + +- systemd is compatible with the SysV init system + - SysV init scripts are read as an alternative (though limited) configuration file format. + - The SysV /dev/initctl interface is provided, and compatibility implementations of the various SysV client tools are available. + - In addition to that, various established Unix functionality such as /etc/fstab or the utmp database are supported. + +- systemd has a minimal transaction system: + - if a unit is requested to start up or shut down, it will add it and all its dependencies to a temporary transaction. + - The temporary transaction will be verified for consistency (what does this entail?). If not consistent, systemd will try to fix it up and remove non-essential jobs from the transaction that might remove the loop (what does loop mean here?). + - Effectively this means that before executing a requested operation, systemd will verify that it makes sense, fixing it if possible, and only failing if it really cannot work. + + - [HARD TO TRANSLATE THIS!] Transactions are generated independently of a unit's state at runtime. For example, if a start job is requested on an already started unit, it will still generate a transaction and wake up any inactive dependencies (and cause propagation of other jobs as per the defined relationships). This is because the enqueued job is at the time of execution compared to the target unit's state and is marked successful and complete when both satisfy. However, this job also pulls in other dependencies due to the defined relationships and thus leads to, in our example, start jobs for any of those inactive units getting queued as well. + +# Systemd Mapper Items +Questions about master struct data: + - What is the reason behind symlinks being both dependencies and aliases (all symlinks are aliases)? + - Why did you not just make the key the full path string (not the remote path but system path)? + - There are units that are not SYMLINKS or FILES. Describe how these function? + - systemd-random-seed.service : not on filesystem?? In a unit file but not a link or file. + - How are we handling units with Condition= and Assert= + - systemd.preset + - Implicit Dependencies for each type. + + + + - ".device", + - ".mount", + - ".automount", + - ".swap", + - ".target", + - ".path", + - ".timer", + - ".slice", + - ".scope". + +#### Requirement Dependencies +- These dependencies DO NOT establish an ORDER! +- Wants, Requires, Conflicts +- systemd uses positive requirement dependencies (i.e., Requires=) signifying this unit REQUIRES the units in the list. +- systemd uses negative requirement dependencies (i.e., Conflicts=) signifying this unit CONFLICTS (or cannot run or be used) with the units in the list. +- If only a requirement dependency exists between two units (e.g. foo.service requires bar.service) and both are requested to start, they will be started in parallel. +- In both cases (wants and requires) the PREFERRED WAY to install these links is to use the [Install] section of that target unit file, systemctl enable (or disable to remove them). + +## Unit File Directives + +### Wants +- A REQUIREMENT dependency +- When THIS UNIT is started, all units listed in the WANTS list will ATTEMPT TO be started immediately and in parallel. +- THIS UNIT file (e.g., foo.service) MAY BE paired with a "wants" directory: foo.service.wants + - All units linked in the example foo.service.wants/ directory will be treated as if they were in the WANTS list in the unit file. +- If ANY of the units in the WANTS list fails to start, then THIS UNIT will still start and its validity will not be effected. + +- Unit --> Unit.wants ----> { all links here } --- for each link --> Unit file + +### Requires +- A REQUIREMENT dependency +- Similar to WANTS but with a stronger dependency. + - All `requires` units SHOULD be activiated. + - If one in the list fails an ordering AFTER= dependency is set on the failing unit (THIS UNIT cannot start until AFTER the failed unit starts) and THIS UNIT WILL NOT START. + - If one of the requires list unit is STOPPED, THIS UNIT WILL BE EXPLICITLY STOPPED. +- Requires DOES NOT IMPLY all the units in THIS UNIT's list always have to be active when this unit is running. +- As with wants directories, requires directories can contain links and they are treated as if they are in the REQUIRES list. + +### Requisite +- A REQUIREMENT dependency +- If the units in the Requisite= list are not ALREADY STARTED, they WILL NOT be started AND this unit will NOT BE STARTED AND FAIL. +- SHOULD be combined with ordering dependency After= to ensure THIS UNIT is not started BEFORE the unit in the Requisite= list. +- Requisite=B in A and RequisiteOf=A in B specify opposite ends of this REQUIREMENT + +### BindsTo +- A REQUIREMENT dependency +- Similar to REQUIRES, but this is stronger. +- If the BindsTo unit STOPS (or enters the INACTIVE state), then THIS UNIT STOPS. +- When combined with After= this becomes stronger yet: the bound to unit must be in the ACTIVE state for THIS UNIT to be in the ACTIVE state. +- BindsTo=B in A and BoundBy=A in B specify opposite ends of this REQUIREMENT + +### PartOf +- Similar to REQUIRES, but limited to Starting and Stopping of Units +- When units IN the PartOf= list are STOPPED or RESTARTED that action is propagated to THIS UNIT +- A ONE WAY dependency; things that happen to THIS UNIT do not propagate to the units in the PartOf= list. +- PartOf=B in A and ConsistsOf=A in B specify opposite ends of this REQUIREMENT; ConsistsOf= cannot exist by itself. + +### Upholds +- Similar to WANTS. +- When THIS UNIT is up (active?), all units in the Upholds list are started when found to be inactive or failed AND no job is queued for them. +- Has a CONTINUOUS effect; this dependency will always act when the condition holds (it is not a one-time thing) +- Upholds=B in A and UpheldBy=A in B specify opposite ends of this REQUIREMENT; UpheldBy= cannot exist by itself. + +### Conflicts= +- A NEGATIVE REQUIREMENTS dependency. +- Starting THIS UNIT will STOP the units in the Conflicts= list. +- Starting the units in the Conflicts= list will STOP THIS UNIT. +- Does NOT imply ordering. +- After= or Before= ordering when applied ensures a conflicting unit is STOPPED before the other unit is started. + +### Ordering Dependencies +- After, Before +- systemd uses ordering dependencies: (i.e., After= and Before=). +- After dependencies indicate this UNIT should be started AFTER the UNITS in the After= list. +- Before dependencies indicate this UNIT should be started BEFORE the UNITS in the Before= list. +- Programs and Units may "request" state changes; requests are encapsulated in JOBS and maintained in a queue. +- Ordering dependencies establish when JOBS will be scheduled. + - JOB: + - A JOB may succeed or fail. + + On boot systemd activates the target unit `default.target` whose job is to activate on-boot services and other on-boot units by pulling them in via dependencies. + USUALLY, the unit name is just an ALIAS (symlink) for either `graphical.target` or `multi-user.target`. See systemd.special(7) for details about these target units. + +#### Before +- An ORDERING dependency. +- In foo.service with Before=bar.service, bar.service's start is DELAYED until foo.service has finished starting. + - foo.service ---> bar.service [ foo before bar, or bar after foo ] +- When two units with a Before= dependency are shutdown, the inverse of the start-up order is applied +- Before= dependencies on device units have NO EFFECT (not supported) + +#### After +- An ORDERING dependency +- The LOGICAL INVERSE of Before= +- In foo.service with After=bar.service, when bar.service has finished starting, foo.service ,may start. + - bar.service ---> foo.service [ bar before foo, or foo after bar ] +- When shutdown the inverse occurs, foo.service ---> bar.service [ foo shutodown, then bar ] +- With ANY ordering dependency (Before= or After=) if one unit is SHUTDOWN, the other is STARTED (SHUTDOWN before STARTED) +- IF no ordering dependency, units can be shutdown or started simultaneously. + +### OnFailure +- A list of units that are activated with THIS UNIT enters the FAILED state. + +### OnSuccess +- A list of units that are activated when this unit enters the INACTIVE state. + +### PropagatesReloadTo, ReloadPropagatedFrom +- When a reload request is issued to THIS UNIT, all units in the `PropagatesReloadTo=` list will also be queued to reload. +- When a reload request is issued from a unit containing this unit in its `ReloadPropagatedFrom=` THIS UNIT will be reloaded. + +### PropagatesStopTo, StopPropagatedFrom +- When a stop request is issued to THIS UNIT, all units in the `PropagatesStopTo=` list will also be queued to stop. +- When a stop request is issued from a unit containing this unit in its `StopPropagatedFrom=` THIS UNIT will be stopped. + +### JoinsNamespaceOf +- Used for units that start processes (service units). +- Lists one or more other units whose network or temporary file namespace to join. + +### RequiresMountsFor +- A list of absolute paths. +- Using this list dependencies of `Requires=` and `After=` are automatically added as dependencies for all mount points required to access the specified paths. + +### OnFailureJobMode +- Specifies how the units listed in the `OnFailure=` directive will be enqueued. + +### IgnoreOnIsolate +- See documentation + +### StopWhenUnneeded +- When TRUE, this unit will be stopped when it is no longer USED. +- This unit will automatically be cleaned up when NO OTHER UNIT REQUIRES it. + +### RefuseManualStart, RefuseManualStop +- This unit can only be activated or deactivated INDIRECTLY and NOT thru the command line or other explicit methods. + +### AllowIsolate +- See documentation. + +### DefaultDependencies +- A boolean value +- If yes/true, default dependencies will be CREATED; what is created depends on the unit type. +- It is recommended that this be set to the default or TRUE for almost all units. + +### CollectMode +- Relates to how this unit is garbage collected. + +### FailureAction, SuccessAction +- The action to take when a UNIT STOPS or enters the FAILED or INACTIVE STATES. + +#### FailureActionExitStatus, SuccessActionExitStatus +- Used in conjunction with FailureAction and SuccessAction + +### JobTimeoutSec, JobRunningTimeoutSec +- Time limitations on Jobs + +#### JobTimeoutAction, JobTimeoutRebootArgument +- Used in conjunction with JobTimeoutSec, etc. + +### StartLimitIntervalSec, StartLimitBurst +- Rate limits unit starting + +#### StartLimitAction +- Used with StartLimitIntervalSec, etc. + +### RebootArgument + +### SourcePath +- The path to the configuration file from which this unit file was generated; used for generator tools and not used in normal units. + +## Groups +- Processes (executables) systemd spawns are placed in individual Linux control groups named after the unit to which they belong. This forms a private systemd hierarchy. + - The systemd hierarchy is used to keep track of processes. + - Control group information is maintained in the kernel. + - Control group information is accessible via the file system hierarchy (sysfs beneath /sys/fs/cgroup/). + - Control group information can also be accessed using tools such as systemd-cgls(1) or ps(1). + - `$ ps xawf -eo pid,user,cgroup,args` + - Useful to list all processes and the systemd units they belong to. + +- systemd is compatible with the SysV init system + - SysV init scripts are read as an alternative (though limited) configuration file format. + - The SysV /dev/initctl interface is provided, and compatibility implementations of the various SysV client tools are available. + - In addition to that, various established Unix functionality such as /etc/fstab or the utmp database are supported. + +- systemd has a minimal transaction system: + - if a unit is requested to start up or shut down, it will add it and all its dependencies to a temporary transaction. + - The temporary transaction will be verified for consistency (what does this entail?). If not consistent, systemd will try to fix it up and remove non-essential jobs from the transaction that might remove the loop (what does loop mean here?). + - Effectively this means that before executing a requested operation, systemd will verify that it makes sense, fixing it if possible, and only failing if it really cannot work. + + - [HARD TO TRANSLATE THIS!] Transactions are generated independently of a unit's state at runtime. For example, if a start job is requested on an already started unit, it will still generate a transaction and wake up any inactive dependencies (and cause propagation of other jobs as per the defined relationships). This is because the enqueued job is at the time of execution compared to the target unit's state and is marked successful and complete when both satisfy. However, this job also pulls in other dependencies due to the defined relationships and thus leads to, in our example, start jobs for any of those inactive units getting queued as well. + +# Systemd Mapper Items +Questions about master struct data: + - What is the reason behind symlinks being both dependencies and aliases (all symlinks are aliases)? + - Why did you not just make the key the full path string (not the remote path but system path)? + - There are units that are not SYMLINKS or FILES. Describe how these function? + - systemd-random-seed.service : not on filesystem?? In a unit file but not a link or file. + - How are we handling units with Condition= and Assert= + - systemd.preset + - Implicit Dependencies for each type. + + + - ".device", + - ".mount", + - ".automount", + - ".swap", + - ".target", + - ".path", + - ".timer", + - ".slice", + - ".scope". + +#### Requirement Dependencies +- These dependencies DO NOT establish an ORDER! +- Wants, Requires, Conflicts +- systemd uses positive requirement dependencies (i.e., Requires=) signifying this unit REQUIRES the units in the list. +- systemd uses negative requirement dependencies (i.e., Conflicts=) signifying this unit CONFLICTS (or cannot run or be used) with the units in the list. +- If only a requirement dependency exists between two units (e.g. foo.service requires bar.service) and both are requested to start, they will be started in parallel. +- In both cases (wants and requires) the PREFERRED WAY to install these links is to use the [Install] section of that target unit file, systemctl enable (or disable to remove them). + +## Unit File Directives + +### Wants +- A REQUIREMENT dependency +- When THIS UNIT is started, all units listed in the WANTS list will ATTEMPT TO be started immediately and in parallel. +- THIS UNIT file (e.g., foo.service) MAY BE paired with a "wants" directory: foo.service.wants + - All units linked in the example foo.service.wants/ directory will be treated as if they were in the WANTS list in the unit file. +- If ANY of the units in the WANTS list fails to start, then THIS UNIT will still start and its validity will not be effected. + +- Unit --> Unit.wants ----> { all links here } --- for each link --> Unit file + +### Requires +- A REQUIREMENT dependency +- Similar to WANTS but with a stronger dependency. + - All `requires` units SHOULD be activiated. + - If one in the list fails an ordering AFTER= dependency is set on the failing unit (THIS UNIT cannot start until AFTER the failed unit starts) and THIS UNIT WILL NOT START. + - If one of the requires list unit is STOPPED, THIS UNIT WILL BE EXPLICITLY STOPPED. +- Requires DOES NOT IMPLY all the units in THIS UNIT's list always have to be active when this unit is running. +- As with wants directories, requires directories can contain links and they are treated as if they are in the REQUIRES list. + +### Requisite +- A REQUIREMENT dependency +- If the units in the Requisite= list are not ALREADY STARTED, they WILL NOT be started AND this unit will NOT BE STARTED AND FAIL. +- SHOULD be combined with ordering dependency After= to ensure THIS UNIT is not started BEFORE the unit in the Requisite= list. +- Requisite=B in A and RequisiteOf=A in B specify opposite ends of this REQUIREMENT + +### BindsTo +- A REQUIREMENT dependency +- Similar to REQUIRES, but this is stronger. +- If the BindsTo unit STOPS (or enters the INACTIVE state), then THIS UNIT STOPS. +- When combined with After= this becomes stronger yet: the bound to unit must be in the ACTIVE state for THIS UNIT to be in the ACTIVE state. +- BindsTo=B in A and BoundBy=A in B specify opposite ends of this REQUIREMENT + +### PartOf +- Similar to REQUIRES, but limited to Starting and Stopping of Units +- When units IN the PartOf= list are STOPPED or RESTARTED that action is propagated to THIS UNIT +- A ONE WAY dependency; things that happen to THIS UNIT do not propagate to the units in the PartOf= list. +- PartOf=B in A and ConsistsOf=A in B specify opposite ends of this REQUIREMENT; ConsistsOf= cannot exist by itself. + +### Upholds +- Similar to WANTS. +- When THIS UNIT is up (active?), all units in the Upholds list are started when found to be inactive or failed AND no job is queued for them. +- Has a CONTINUOUS effect; this dependency will always act when the condition holds (it is not a one-time thing) +- Upholds=B in A and UpheldBy=A in B specify opposite ends of this REQUIREMENT; UpheldBy= cannot exist by itself. + +### Conflicts= +- A NEGATIVE REQUIREMENTS dependency. +- Starting THIS UNIT will STOP the units in the Conflicts= list. +- Starting the units in the Conflicts= list will STOP THIS UNIT. +- Does NOT imply ordering. +- After= or Before= ordering when applied ensures a conflicting unit is STOPPED before the other unit is started. + +### Ordering Dependencies +- After, Before +- systemd uses ordering dependencies: (i.e., After= and Before=). +- After dependencies indicate this UNIT should be started AFTER the UNITS in the After= list. +- Before dependencies indicate this UNIT should be started BEFORE the UNITS in the Before= list. +- Programs and Units may "request" state changes; requests are encapsulated in JOBS and maintained in a queue. +- Ordering dependencies establish when JOBS will be scheduled. + - JOB: + - A JOB may succeed or fail. + + On boot systemd activates the target unit `default.target` whose job is to activate on-boot services and other on-boot units by pulling them in via dependencies. + USUALLY, the unit name is just an ALIAS (symlink) for either `graphical.target` or `multi-user.target`. See systemd.special(7) for details about these target units. + +#### Before +- An ORDERING dependency. +- In foo.service with Before=bar.service, bar.service's start is DELAYED until foo.service has finished starting. + - foo.service ---> bar.service [ foo before bar, or bar after foo ] +- When two units with a Before= dependency are shutdown, the inverse of the start-up order is applied +- Before= dependencies on device units have NO EFFECT (not supported) + +#### After +- An ORDERING dependency +- The LOGICAL INVERSE of Before= +- In foo.service with After=bar.service, when bar.service has finished starting, foo.service ,may start. + - bar.service ---> foo.service [ bar before foo, or foo after bar ] +- When shutdown the inverse occurs, foo.service ---> bar.service [ foo shutodown, then bar ] +- With ANY ordering dependency (Before= or After=) if one unit is SHUTDOWN, the other is STARTED (SHUTDOWN before STARTED) +- IF no ordering dependency, units can be shutdown or started simultaneously. + +### OnFailure +- A list of units that are activated with THIS UNIT enters the FAILED state. + +### OnSuccess +- A list of units that are activated when this unit enters the INACTIVE state. + +### Triggers= +- Created implicitly between a socket, path, or automount unit and the unit they activate. + - TriggeredBy= is created implicitly on the triggered unit. +- Default: a unit with the same name is triggered. +- Override: Use Sockets=, Service=, and Unit= settings to OVERRIDE default naming behavior. + +### PropagatesReloadTo, ReloadPropagatedFrom +- When a reload request is issued to THIS UNIT, all units in the `PropagatesReloadTo=` list will also be queued to reload. +- When a reload request is issued from a unit containing this unit in its `ReloadPropagatedFrom=` THIS UNIT will be reloaded. + +### PropagatesStopTo, StopPropagatedFrom +- When a stop request is issued to THIS UNIT, all units in the `PropagatesStopTo=` list will also be queued to stop. +- When a stop request is issued from a unit containing this unit in its `StopPropagatedFrom=` THIS UNIT will be stopped. + +### JoinsNamespaceOf +- Used for units that start processes (service units). +- Lists one or more other units whose network or temporary file namespace to join. + +### RequiresMountsFor +- A list of absolute paths. +- Using this list dependencies of `Requires=` and `After=` are automatically added as dependencies for all mount points required to access the specified paths. + +### OnFailureJobMode +- Specifies how the units listed in the `OnFailure=` directive will be enqueued. + +### IgnoreOnIsolate +- See documentation + +### StopWhenUnneeded +- When TRUE, this unit will be stopped when it is no longer USED. +- This unit will automatically be cleaned up when NO OTHER UNIT REQUIRES it. + +### RefuseManualStart, RefuseManualStop +- This unit can only be activated or deactivated INDIRECTLY and NOT thru the command line or other explicit methods. + +### AllowIsolate +- See documentation. + +### DefaultDependencies +- A boolean value +- If yes/true, default dependencies will be CREATED; what is created depends on the unit type. +- It is recommended that this be set to the default or TRUE for almost all units. + +### CollectMode +- Relates to how this unit is garbage collected. + +### FailureAction, SuccessAction +- The action to take when a UNIT STOPS or enters the FAILED or INACTIVE STATES. + +#### FailureActionExitStatus, SuccessActionExitStatus +- Used in conjunction with FailureAction and SuccessAction + +### JobTimeoutSec, JobRunningTimeoutSec +- Time limitations on Jobs + +#### JobTimeoutAction, JobTimeoutRebootArgument +- Used in conjunction with JobTimeoutSec, etc. + +### StartLimitIntervalSec, StartLimitBurst +- Rate limits unit starting + +#### StartLimitAction +- Used with StartLimitIntervalSec, etc. + +### RebootArgument + +### SourcePath +- The path to the configuration file from which this unit file was generated; used for generator tools and not used in normal units. + +## Groups +- Processes (executables) systemd spawns are placed in individual Linux control groups named after the unit to which they belong. This forms a private systemd hierarchy. + - The systemd hierarchy is used to keep track of processes. + - Control group information is maintained in the kernel. + - Control group information is accessible via the file system hierarchy (sysfs beneath /sys/fs/cgroup/). + - Control group information can also be accessed using tools such as systemd-cgls(1) or ps(1). + - `$ ps xawf -eo pid,user,cgroup,args` + - Useful to list all processes and the systemd units they belong to. + +- systemd is compatible with the SysV init system + - SysV init scripts are read as an alternative (though limited) configuration file format. + - The SysV /dev/initctl interface is provided, and compatibility implementations of the various SysV client tools are available. + - In addition to that, various established Unix functionality such as /etc/fstab or the utmp database are supported. + +- systemd has a minimal transaction system: + - if a unit is requested to start up or shut down, it will add it and all its dependencies to a temporary transaction. + - The temporary transaction will be verified for consistency (what does this entail?). If not consistent, systemd will try to fix it up and remove non-essential jobs from the transaction that might remove the loop (what does loop mean here?). + - Effectively this means that before executing a requested operation, systemd will verify that it makes sense, fixing it if possible, and only failing if it really cannot work. + + - [HARD TO TRANSLATE THIS!] Transactions are generated independently of a unit's state at runtime. For example, if a start job is requested on an already started unit, it will still generate a transaction and wake up any inactive dependencies (and cause propagation of other jobs as per the defined relationships). This is because the enqueued job is at the time of execution compared to the target unit's state and is marked successful and complete when both satisfy. However, this job also pulls in other dependencies due to the defined relationships and thus leads to, in our example, start jobs for any of those inactive units getting queued as well. + +# Systemd Mapper Items +Questions about master struct data: + - What is the reason behind symlinks being both dependencies and aliases (all symlinks are aliases)? + - Why did you not just make the key the full path string (not the remote path but system path)? + - There are units that are not SYMLINKS or FILES. Describe how these function? + - systemd-random-seed.service : not on filesystem?? In a unit file but not a link or file. + - How are we handling units with Condition= and Assert= + - systemd.preset + - Implicit Dependencies for each type. + +## Aggregation +- `.d` dropin directory: all directives in `.conf` files in this directory need to be added to the unit object they are associated with (dropins) +- `.wants` directory: all the links (full path) here need to be added to the Wants= directive in the base unit. +- `.requires` directory: all the links (full path) here need to be added to the Requires= directive in the base unit. + +## Dependency Relations +- What is the fundamental meaning of the ( source, target ) relationship? + - "Depends On" or, source cannot function unless I attempt or succeed in starting / using the target. + +- There are three types of fundamental relationships to distinguish in Systemd: + - "Depends On" : these are the wants, requires, conflicts, etc. + - UNDIRECTED EDGES. + - HOW these are related will be indicated by COLOR. + - The edge label will be the directive. + - So, these DO NOT define any order only some sort of relationship. + - "Order" : after and before + - DIRECTED EDGES. 1 --> 2 is equivalent to 1 BEFORE 2 or 2 AFTER 1. + - Edge color == black. + - The label SHOULD NOT BE NEEDED, but we can add AFTER and BEFORE to make clear which unit file the directive was in. + - "Aliased" : symlinks. + - All aliases in the dependency graph should be identified by full paths. + - DIRECTED EDGES. Alias --> Target + - Edge Color == green + - The label SHOULD BE ALIAS, although if the color is distinct it shouldn't be needed. + - As long as the shared names for the edges (id) are different you can build a multigraph. + +### Alias Relations +- Aliases: ( Alias [full path], Target ) + +### Order Relations +- In a, Before=b.service: a.service --> b.service +- In a, After=b.service: b.service --> a.service + +### Depends On Relations +- All the directives that provide a LIST OF OTHER UNITS. +- Wants: ( THIS UNIT, { each unit in wants= list } ) + - wants directory ( THIS UNIT, { all links in wants dir for unit } ) + - A WEAK dependency (no arrows; these can be started in parallel) +- Requires: ( THIS UNIT, { each unit in requires= list OR in requires directory } ) + - A STRONG dependency ( maybe a diamond arrow head? ) +- Requisite: ( THIS UNIT, { Requisite list } ) + - The STRONGEST dependency ( all in the relation must be running and those in the target position must start first ) +- BindsTo: ( THIS UNIT, { BindsTo list } ) + - The STRONGEST dependency: if a bindsto unit stops, this unit stops. +- PartOf: ( { PartOf list }, THIS UNIT ) + - This is like unit object-oriented composition: things that happen to units in the PartOf list happen to THIS UNIT. + - Use the composition arrow head. +- Upholds: ( THIS UNIT, { Upholds list } ) + - When THIS UNIT up, all in upholds list will be started (has continuous effect) +- Conflicts: ( THIS UNIT, { Conflicts list } ) + - THIS IS A NEGATIVE DEPENDENCY; the items in conflicts list will stop if THIS UNIT is started and vice versa + - Need a way to IDENIFY NEGATIVE DEPENDENCIES. + - This should be a double arrow since the effects occur both ways. +- Sockets= : ( { sockets, ... }, THIS UNIT ) + - should be directed edge THIS UNIT inherits this set of sockets. +- Service= : in a socket unit, this makes explict which service unit this socket unit matches. The default behavior (I don't think you need to use the Service= directive) is that the service unit will have the same name as the socket unit with the extension replaced. + + +- OnFailure, OnSuccess +- PropagatesReloadTo, ReloadPropagatedFrom +- PropagatesStopTo, StopPropagatedFrom +- JoinsNamespaceOf +- RequiresMountsFor diff --git a/data/._.gitkeep b/data/._.gitkeep new file mode 100644 index 0000000000000000000000000000000000000000..5c590df09d078c41baea8693031213f5fc9e4117 GIT binary patch literal 176 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUBqY_#1$j2;dkJ5(HHS(lG;wCD61n zBE&_L^K None: + '''Verify DepMapUnit objects are built correctly''' + + dep_unit = DepMapUnit('test_unit.wants', 'None', 'None') + + self.assertTrue(hasattr(dep_unit, 'sym_linked_to')) + self.assertTrue(hasattr(dep_unit, 'sym_linked_from')) + self.assertTrue(hasattr(dep_unit, 'wants')) + self.assertTrue(hasattr(dep_unit, 'wanted_by')) + self.assertTrue(hasattr(dep_unit, 'requires')) + self.assertTrue(hasattr(dep_unit, 'required_by')) + self.assertTrue(hasattr(dep_unit, 'requisite')) + self.assertTrue(hasattr(dep_unit, 'requisite_of')) + self.assertTrue(hasattr(dep_unit, 'bindsto')) + self.assertTrue(hasattr(dep_unit, 'bound_by')) + self.assertTrue(hasattr(dep_unit, 'partof')) + self.assertTrue(hasattr(dep_unit, 'has_part')) + self.assertTrue(hasattr(dep_unit, 'upholds')) + self.assertTrue(hasattr(dep_unit, 'upheld_by')) + self.assertTrue(hasattr(dep_unit, 'onsuccess')) + self.assertTrue(hasattr(dep_unit, 'on_success_of')) + self.assertTrue(hasattr(dep_unit, 'sockets')) + self.assertTrue(hasattr(dep_unit, 'socket_of')) + self.assertTrue(hasattr(dep_unit, 'service')) + self.assertTrue(hasattr(dep_unit, 'uses_service')) + self.assertTrue(hasattr(dep_unit, 'parents')) + self.assertTrue(hasattr(dep_unit, 'reverse_deps')) + self.assertTrue(hasattr(dep_unit, 'dependencies')) + + + def test_update_ms_dep_dir(self) -> None: + '''Verify the actual parsing for master struct dependency directories is correct''' + + test_ms_unit_struct = { + 'metadata': { + 'unit_file': 'test_unit.wants', + 'Wants': ['wants1.target', 'wants2.target'], + 'Requires': ['requires1.target', 'requires2.target'], + 'd': ['config1.cfg', 'config2.cfg'], + 'file_type': 'dep_dir' + } + } + + dep_unit = DepMapUnit('test_unit.wants', 'None', 'None') + dep_unit.update_ms_dep_dir(test_ms_unit_struct) + + self.assertIn('wants1.target', dep_unit.wants) + self.assertIn('wants2.target', dep_unit.wants) + self.assertIn('requires1.target', dep_unit.requires) + self.assertIn('requires2.target', dep_unit.requires) + self.assertIn('wants1.target', dep_unit.dependencies) + self.assertIn('wants2.target', dep_unit.dependencies) + self.assertIn('requires1.target', dep_unit.dependencies) + self.assertIn('requires2.target', dep_unit.dependencies) + + self.assertFalse(hasattr(dep_unit, 'unit_file')) + self.assertFalse(hasattr(dep_unit, 'd')) + self.assertFalse(hasattr(dep_unit, 'file_type')) + + self.assertNotIn('requires1.target', dep_unit.wants) + self.assertNotIn('config1.cfg', dep_unit.wants) + self.assertNotIn('test_unit.wants', dep_unit.wants) + self.assertNotIn('dep_dir', dep_unit.wants) + self.assertNotIn('wants1.target', dep_unit.requires) + self.assertNotIn('config1.cfg', dep_unit.requires) + self.assertNotIn('test_unit.wants', dep_unit.requires) + self.assertNotIn('dep_dir', dep_unit.requires) + + + def test_update_ms_sym_link(self) -> None: + '''Verify the actual parsing for master struct symbolic links is correct''' + + test_sym_link_struct = { + "metadata": { + "unit_file": "sym_link.target", + "file_type": "sym_link", + "sym_link_path": "/etc/systemd/system/", + "sym_link_unit": "sym_link.target", + "sym_link_target_path": "/lib/systemd/system/", + "sym_link_target_unit": "target.target", + "dependencies": [ "target.target" ] + } + } + + test_ms_sym_link_unit = DepMapUnit('sym_link.target', 'None', 'None') + test_ms_sym_link_unit.update_ms_sym_link(test_sym_link_struct) + + self.assertTrue(test_ms_sym_link_unit.sym_linked_to == {'/lib/systemd/system/target.target'}) + self.assertTrue(test_ms_sym_link_unit.dependencies == {'target.target'}) + + + def test_update_ms_unit_file(self) -> None: + '''Verify the actual parsing for master struct unit files is correct''' + + test_ms_unit_file_struct = { + "metadata": { "file_type": "unit_file" }, + "Description": [ "Some random unit file" ], + "PartOf": [ "enabled.target", "lxdm.target" ], + "Wants": [ "graphical.target" ], + "Before": [ "enabled.target" ], + "OnFailure": [ "failure.service" ], + "OnSuccess": [ "success.service" ], + "Requisite": [ "default.target" ], + "Requires": [ "multi-user.target" ], + "LimitCORE": [ "infinity" ], + "BindsTo": [ "multi-user.target", "graphical.target" ], + "LimitNOFILE": [ "infinity " ], + "RuntimeDirectory": [ "dir" ], + "RuntimeDirectoryPreserve": [ "yes" ], + "ExecStartPre": [ "rm -f /var/lib/bin/*.frc", "rm -f /var/lib/bin_failover/*.frc" ], + "ExecStart": [ "/usr/bin/bin /run/bin/bin.cfg" ], + "ExecStopPost": [ "/usr/bin/watchdogtickle -a" ], + "Upholds": [ "default.target" ], + "Sockets": [ "socket.socket" ], + "Service": [ "service.service" ], + "WantedBy": [ "enabled.target" ] + } + + test_ms_unit_file = DepMapUnit('unit.target', 'None', 'None') + test_ms_unit_file.update_ms_unit_file(test_ms_unit_file_struct) + + self.assertIn('enabled.target', test_ms_unit_file.partof) + self.assertIn('lxdm.target', test_ms_unit_file.partof) + self.assertIn('multi-user.target', test_ms_unit_file.bindsto) + self.assertIn('graphical.target', test_ms_unit_file.bindsto) + + self.assertTrue(test_ms_unit_file.requires == {'multi-user.target'}) + self.assertTrue(test_ms_unit_file.wants == {'graphical.target'}) + self.assertTrue(test_ms_unit_file.requisite == {'default.target'}) + self.assertTrue(test_ms_unit_file.upholds == {'default.target'}) + self.assertTrue(test_ms_unit_file.onsuccess == {'success.service'}) + self.assertTrue(test_ms_unit_file.sockets == {'socket.socket'}) + self.assertTrue(test_ms_unit_file.service == {'service.service'}) + + self.assertNotIn('enabled.target', test_ms_unit_file.wanted_by) + + + def test_set_rev_dep(self) -> None: + '''Verify reverse dependency attributes are being mapped correctly''' + + test_unit = DepMapUnit('multi-user.target', 'default.target', 'wanted_by') + test_unit.set_rev_dep() + + # Overwrite original instance to verify values append and not overwrite if + # the unit is called more than once + test_unit.rev_dep = 'required_by' + test_unit.parent_unit = 'multi-user.target' + test_unit.set_rev_dep() + + test_unit.rev_dep = 'sym_linked_from' + test_unit.parent_unit_path = '/etc/systemd/system/graphical.target' + test_unit.parent_unit = 'graphical.target' + test_unit.set_rev_dep() + + test_unit.rev_dep = 'invalid' + test_unit.parent_unit = 'invalid.target' + test_unit.set_rev_dep() + + with self.assertLogs('root', level='WARNING') as logs: + logging.getLogger('root').warning('Invalid reverse dependency: invalid. This does not map to any attribute sets') + + self.assertIn('default.target', test_unit.parents) + self.assertIn('multi-user.target', test_unit.parents) + self.assertIn('graphical.target', test_unit.parents) + self.assertIn('default.target', test_unit.wanted_by) + self.assertIn('multi-user.target', test_unit.required_by) + self.assertIn('/etc/systemd/system/graphical.target', test_unit.sym_linked_from) + self.assertIn('wanted_by', test_unit.reverse_deps) + self.assertIn('required_by', test_unit.reverse_deps) + self.assertIn('sym_linked_from', test_unit.reverse_deps) + + self.assertEqual(logs.output, ['WARNING:root:Invalid reverse dependency: invalid. This does not map to any attribute sets']) + + self.assertNotIn('/etc/systemd/system/multi-user.target', test_unit.parents) + + + def test_load_from_ms(self) -> None: + '''Verify master struct entries are identified correctly. See above for parsing verification''' + + test_config_dir_entry = { + "metadata": { "file_type": "dep_dir" } + } + test_wants_dir_entry = { + "metadata": { + "file_type": "dep_dir", + "dependency_folder_paths": [ "/etc/systemd/system/timers.target.wants" ], + "Wants": [ "logrotate.timer", "hwclock-sync.timer" ] + } + } + test_requires_dir_entry = { + "metadata": { + "file_type": "dep_dir", + "dependency_folder_paths": [ "/etc/systemd/system/random.service.requires" ], + "Requires": [ "another.service" ] + } + } + test_sym_link_entry = { + "metadata": { + "file_type": "sym_link", + "sym_link_target_path": "/lib/systemd/system/", + "sym_link_target_unit": "rsyslog.service", + "dependencies": [ "rsyslog.service" ] + } + } + test_unit_file_entry = { + "metadata": { "file_type": "unit_file" }, + "Requires": [ "syslog.socket" ] + } + test_invalid_file_entry = { + "metadata": { "file_type": "invalid" } + } + + test_unit = DepMapUnit('default.target', 'None', 'None') + test_unit.load_from_ms(test_config_dir_entry) + + test_unit.load_from_ms(test_wants_dir_entry) + self.assertIn('logrotate.timer', test_unit.wants) + self.assertIn('hwclock-sync.timer', test_unit.wants) + + test_unit.load_from_ms(test_requires_dir_entry) + self.assertTrue(test_unit.requires == {'another.service'}) + + test_unit.load_from_ms(test_sym_link_entry) + self.assertTrue(test_unit.sym_linked_to == {'/lib/systemd/system/rsyslog.service'}) + + test_unit.load_from_ms(test_unit_file_entry) + self.assertIn('syslog.socket', test_unit.requires) + + test_unit.load_from_ms(test_invalid_file_entry) + with self.assertLogs('root', level='WARNING') as logs: + logging.getLogger('root').warning('Not sure how to parse file type: invalid from default.target') + self.assertEqual(logs.output, ['WARNING:root:Not sure how to parse file type: invalid from default.target']) + + + def test_load_from_dep_map(self) -> None: + '''Verifying correct loading of previously recorded dependency map entries. See above for parsing verification''' + + test_old_dep_map_entry = { + "unit_name": "multi-user.target", + "parents": [ "enabled.target" ], + "reverse_deps": [ "required_by" ], + "Wants": [ "gfarmond.service", "rngd.service", "dnsmasq.service" ], + "Requires": [ "basic.target" ], + "required_by": [ "graphical.target" ], + "dependencies": [ "gfarmond.service", "rngd.service", "dnsmasq.service", "basic.target" ] + } + + test_unit = DepMapUnit('multi-user.target', 'default.target', 'required_by') + test_unit.load_from_dep_map(test_old_dep_map_entry) + with self.assertLogs('root', level='WARNING') as logs: + logging.getLogger('root').warning('Could not load "{dep}" attribute from unit already in dep map. If this is not "unit_file" investigate') + + self.assertTrue(test_unit.rev_dep == 'required_by') + self.assertTrue(test_unit.reverse_deps == {'required_by'}) + self.assertTrue(test_unit.requires == {'basic.target'}) + + self.assertIn('default.target', test_unit.required_by) + self.assertIn('graphical.target', test_unit.required_by) + self.assertIn('enabled.target', test_unit.parents) + self.assertIn('default.target', test_unit.parents) + self.assertIn('gfarmond.service', test_unit.wants) + self.assertIn('rngd.service', test_unit.wants) + self.assertIn('dnsmasq.service', test_unit.wants) + self.assertIn('gfarmond.service', test_unit.dependencies) + self.assertIn('rngd.service', test_unit.dependencies) + self.assertIn('dnsmasq.service', test_unit.dependencies) + self.assertIn('basic.target', test_unit.dependencies) + + self.assertEqual(logs.output, ['WARNING:root:Could not load "{dep}" attribute from unit already in dep map. If this is not "unit_file" investigate']) + + + def test_create_dep_tups(self) -> None: + '''Verify correct creation of dependency tuples. See above for parsing verification''' + + test_unit = DepMapUnit('test.target', 'None', 'None') + test_unit.sym_linked_to = {'/path/to/sym_link.target'} + + test_dep_tups_list = test_unit.create_dep_tups('/path/to/test.target') + self.assertIn(('sym_link.target', '/path/to/test.target', 'sym_linked_from'), test_dep_tups_list) + + test_unit.sym_linked_from = {'/path/to/parent/sym_link'} + test_unit.wants = {'wants.target'} + test_unit.wanted_by = {'wanted_by.target'} + test_unit.requires = {'requires.target'} + test_unit.requisite = {'requisite.target'} + test_unit.bindsto = {'bindsto.target'} + test_unit.partof = {'partof.target'} + test_unit.upholds = {'upholds.target'} + test_unit.onsuccess = {'onsuccess.target'} + test_unit.sockets = {'sockets.socket'} + test_unit.service = {'service.service'} + test_unit.itimer_for = {'timer.service'} + test_unit.isocket_of = {'socket.service'} + test_unit.ipath_for = {'path.service'} + + test_dep_tups_list = test_unit.create_dep_tups('test.target') + + self.assertIn(('wants.target', 'test.target', 'wanted_by'), test_dep_tups_list) + self.assertIn(('requires.target', 'test.target', 'required_by'), test_dep_tups_list) + self.assertIn(('requisite.target', 'test.target', 'requisite_of'), test_dep_tups_list) + self.assertIn(('bindsto.target', 'test.target', 'bound_by'), test_dep_tups_list) + self.assertIn(('partof.target', 'test.target', 'has_part'), test_dep_tups_list) + self.assertIn(('upholds.target', 'test.target', 'upheld_by'), test_dep_tups_list) + self.assertIn(('onsuccess.target', 'test.target', 'on_success_of'), test_dep_tups_list) + self.assertIn(('sockets.socket', 'test.target', 'socket_of'), test_dep_tups_list) + self.assertIn(('service.service', 'test.target', 'uses_service'), test_dep_tups_list) + self.assertIn(('timer.service', 'test.target', 'has_timer'), test_dep_tups_list) + self.assertIn(('socket.service', 'test.target', 'has_socket'), test_dep_tups_list) + self.assertIn(('path.service', 'test.target', 'needs_path'), test_dep_tups_list) + + + def test_get_significant_attributes(self) -> None: + '''Verify only interesting attributes are returned''' + + test_unit = DepMapUnit('default.target', '/path/to/default.target', 'required_by') + significant_attributes = test_unit.get_significant_attributes() + + self.assertIn('unit_name', significant_attributes) + self.assertIn('parents', significant_attributes) + self.assertIn('reverse_deps', significant_attributes) + + self.assertNotIn('parent_unit_path', significant_attributes) + + test_unit.wants = {'unit1.target', 'unit2.target', 'unit3.target'} + test_unit.requires = {'unit4.target', 'unit5.target'} + test_unit.required_by = {'unit6.target'} + significant_attributes: List = test_unit.get_significant_attributes() + + self.assertIn('wants', significant_attributes) + self.assertIn('requires', significant_attributes) + self.assertIn('required_by', significant_attributes) + + + def test_dep_map_unit_record(self) -> None: + '''Verify record function transforms sets to lists and returns a dictionary of values''' + + test_unit = DepMapUnit('multi-user.target', '/path/to/default.target', 'required_by') + + test_unit.requires = {'unit1.target', 'unit2.target', 'unit3.target'} + test_unit.sockets = {'unit4.target', 'unit5.target'} + test_unit.uses_service = {'unit6.target'} + test_unit.service = {'unit7.target'} + test_unit.wanted_by = {'unit8.target'} + + out_struct = test_unit.record() + + self.assertTrue(isinstance(out_struct, dict)) + + self.assertIn('unit_name', out_struct) + self.assertIn('parents', out_struct) + self.assertIn('reverse_deps', out_struct) + self.assertIn('requires', out_struct) + self.assertIn('sockets', out_struct) + self.assertIn('uses_service', out_struct) + self.assertIn('service', out_struct) + self.assertIn('wanted_by', out_struct) + + self.assertTrue(isinstance(out_struct['requires'], list)) + self.assertTrue(isinstance(out_struct['sockets'], list)) + self.assertTrue(isinstance(out_struct['uses_service'], list)) + self.assertTrue(isinstance(out_struct['service'], list)) + self.assertTrue(isinstance(out_struct['wanted_by'], list)) + + +def get_dep_map_unit_tests() -> unittest.TestSuite: + '''Create a test suite to test all dep map unit functions''' + + dep_map_test_suite = unittest.TestSuite() + dep_map_test_suite.addTest(TestDepMapUnits('test_DepMapUnit_creation')) + dep_map_test_suite.addTest(TestDepMapUnits('test_update_ms_dep_dir')) + dep_map_test_suite.addTest(TestDepMapUnits('test_update_ms_sym_link')) + dep_map_test_suite.addTest(TestDepMapUnits('test_update_ms_unit_file')) + dep_map_test_suite.addTest(TestDepMapUnits('test_set_rev_dep')) + dep_map_test_suite.addTest(TestDepMapUnits('test_load_from_ms')) + dep_map_test_suite.addTest(TestDepMapUnits('test_load_from_dep_map')) + dep_map_test_suite.addTest(TestDepMapUnits('test_create_dep_tups')) + dep_map_test_suite.addTest(TestDepMapUnits('test_get_significant_attributes')) + dep_map_test_suite.addTest(TestDepMapUnits('test_dep_map_unit_record')) + + return dep_map_test_suite + + +def main() -> None: + runner = unittest.TextTestRunner() + + print('\nTesting dependency mapping functions...') + runner.run(get_dep_map_unit_tests()) + print('No artifacts to clean up') + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/graph_test.py b/graph_test.py new file mode 100755 index 0000000..3f05c20 --- /dev/null +++ b/graph_test.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 + +import networkx as nx + +nodes = [ + ( 1, { 'name':'a' } ), + ( 2, { 'name':'b' } ), + ( 3, { 'name':'c' } ), + ( 4, { 'name':'d' } ), + ( 5, { 'name':'e' } ), + ( 6, { 'name':'f' } ), + ( 7, { 'name':'g' } ), + ( 8, { 'name':'h' } ), + ( 9, { 'name':'i' } ), + ( 10, { 'name':'j' } ), + ( 11, { 'name':'k' } ) + ] + +edges = [ + (1,2,{ 'att':1.0} ), + (2,3,{ 'att':2.0} ), + (2,1,{ 'att':2.5} ), + (3,5,{ 'att':3.5} ), + (4,5,{ 'att':2.2} ), + (5,6,{ 'att':3.3} ), + (6,7,{ 'att':4.5} ), + (7,8,{ 'att':6.7} ), + (8,9,{ 'att':1.3} ), + (9,10,{ 'att':1.4} ), + (9,11,{ 'att':1.8} ) + ] + +def f( item ): + return (item[0] == 1 or item[1] == 1) + +G = nx.DiGraph() + +new_edges = filter( f, edges ) +print( list(new_edges) ) +S = set([ 8, 9 ] ) +print( list( filter( lambda x : x[0] == 1 or x[1] == 1 or x[0] in S, edges ) ) ) + +# for n in nodes: +# G.add_node( n[0], **n[1] ) +# +# for e in edges: +# G.add_edge( e[0], e[1], **e[2] ) +# +# print("{}".format( G.adj )) +# +# for v, data in G.nodes( data=True ): +# print("{} : {}".format( v, data )) +# +# for s, t, data in G.edges( data=True ): +# print("( {}, {} ): {}".format( s, t, data )) +# +# +# print("Tree ====") +# +# T = nx.dfs_tree( G, source=1 ) +# +# T.update( edges = ..., nodes = ... ) +# +# for v in T: +# T.add_node( v, **G.nodes[v] ) +# +# for s, t in T.edges(): +# T.add_edge( s, t, **G.edges[s,t] ) +# +# +# +# for v, data in T.nodes( data=True ): +# print("{} : {}".format( v, data )) +# +# for s, t, data in T.edges( data=True ): +# print("( {}, {} ): {}".format( s, t, data )) +# diff --git a/lib/._.gitkeep b/lib/._.gitkeep new file mode 100644 index 0000000000000000000000000000000000000000..5c590df09d078c41baea8693031213f5fc9e4117 GIT binary patch literal 176 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUBqY_#1$j2;dkJ5(HHS(lG;wCD61n zBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S nBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S nBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S nBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S nBE&_L^KibAmj5!f;%$%N#h>go4Jn(}CR=xL!S zza>X59QX|!_!o2KPf+B->yhM`J3Xb=_m=Md-qJI2Z_i55s15&g{vh<15w4GI627Ki z_$y72c$R<-NWvzhAPpJF!WL}5++itF@X1Qf!l&>VJcJy44quS} z797AKn?Sl))xAdxHc-!~HpYv#Lpf`|MF`&>b9DPrAd$=NCivq}A`Ydc$sKz|Q;Z z*>;PXHk?nKZvsYA$H12oCKstR++Xyh8v>6=lqiOU7%fi z@jC9YQ*KMewNwksYGWvr8<-uB85>^Jej+`fBRy|jNFhDqYobCO5^t^<<=1pfTo@CG zmeR{F_n^eiDG$qljXvfwmPkt_b(arhcg2Ky#cVsJx>C5Yk#@{m)>0q3npRLNxjx+t z4E6D{iw@&H3aOD|zdQ$NW1hOSAQaMtS*j0-0^K0fcCQzh&WjfvPVo71iJ@>9aL-)0 zuwcE&J`hFo5NJB)AyAA5foj=2#-|BRgA=INxqZ|mP_}ElStnpugbgCM6=gf8owH7F z7^PdM-PRyVUG%!cDAVqr|IqD4*^~3LvsSMiS)HFcCl|xkqti~5J?%c~xB5?`%wX8> z_P(XhPuojBe^-0nX1WE6PsK#2-K^hTCWkP*&M+D2O3Zj1V~Y}&2dNxMw3_`k6b!R z0auq%#+Pc!y(sOuyqu~{OMgLnjH-%6=c zsv9;{tYWiDpkA$4ZIfYM;w53I3r9HEptG~)Y-&Mu(*$_SOt;NsjwiobrNZL^i0aY0 z616q3D{-ed4%F?VUe1cLqY+3k8b!Cz!zudl$H5!LjTuI}HV<8~mUhg+YhgEz`AUi_ zxR!QIRo8+=bzbAWWo56#WnEU{ZwYnZ#9^j?Dus!hVqr>Oph8=UnV}XI28&SR6h;G| z2jN6&dSBbiD`viA(bkt!2{RAB+Mq4M7c`|3$$QC->rdwI(#&d@O;Do literal 0 HcmV?d00001 diff --git a/lib/__pycache__/colors.cpython-312.pyc b/lib/__pycache__/colors.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..af28131b70520c7ed258af83c0b8b600a93ce3eb GIT binary patch literal 2255 zcmZ`)%W@k<6qRE=?6#f6c1(ivpm7MqU`v)I>*1nOrC5RpY?+c|Lb7r7^zD(RJenE0 zTPR{>zJg7v*sx&%zrY8uVVRmOKVVmiO;+3z4`zd4p zj_5B#OvdaOy!;u#k1>cnjlmRLf@z3D0+Nt|8JK-O^K=Rb^5$S37T_{mfvd2H+)J+3OC_aM8{FK3b)}7yayEJ33wl6YjAf=Cvp7^_yF#W=@fhj zAHjWChmYYC)SrP3*n~7>U@M|$huQUQ_Um*kM*5zkk8Q^l$F8HaN(Vzy>Fcg9)h_*l zDc_?9X}ZUx7IK3gI46QW>IJ6w25 z0PTB#8oDcJC14!?#*z&>UO>0gTlBtBSR0Gh*2&QC(;gdA&)2lC1g>`!HLgtHvN84Lz#7CD*r zWhdi~LQt9g`d+Qks%@g9gdBOUP>M?NZQqd?pEaZ`KtDLM&^wn6s4pq&NP#l#8$Soq z(T>;Ipvt%PfJs3B(=8pVum6>wvF{WkeNzF;z&yHEY0-LXjXtcj>a7j(xZZwrc+{qk zE6rx5(XQ88^sq_y4ja{ayMEX}>N8qtJfUCK8`TXeFg;iV@hp(WcXZG(sS}VUEn$*- zQ59;V(9kLn+_4>w5qX_H>j>KMzZ25KKxiOj&rzn>3S9%@Iz2}-9nr7LeNhUs=X;}Y zO=m?3KV&Dg=>S@7MC+JK_nGBOrhPdiq$(7bPGBtVQrf<#eb;vZw{+b}A-V6Oza}c7 z75dFI#r&F%i3(!^(Z27dM>a6A1I)uHV8f4m>G%Sd`q*6_pxohI>=k3ShjqnpBOzK$ z6Efl}N8<`?CC9^)A(Y-7bL_N=mMS~({`^Hn9hq9 zZFa@8(GugrX24u?;8ccupSsJp%!8n8n+L&GG!W#s&0}=RV3!zzVv$)pWdz$+ft8C0 ztQ@z1ug`?3+J5bz)@X-`%6`4l3gbtOdOJ*3n}=W38)0hi@Zg}*sD`BWO>OU}U3s`) z3sd{`hs{d!NtkT4oAt)$XwHsC1dJC5%%44S%eEtqQ5l0bM7E7iBrwxjgbB-K{3M*l zlN81+w~riibi6P*6t3$J!er2wfh)o|A2JU&;a0Yk1q21QlVv#s+qsgJ-!ULt%vuIO zIbYn$t|#O?evsu=GhH^*0#0EPyUgndRI@QNd6TQMC^KF{P|Wjuv4EgpF#!hHP=Xvs zfQ-ClwiB3OVG_%tv7QLyz~o7o^nBT4ZkTYLPFG8FU4-$DWJ6RnnJHNa3OTD3rAXLz zE&?`ZqMSvMEyNBOySZ#GTQuI}MP8Ki2#Wb)-ZB|xS(fF-b#8N8lyL8q*&AC>E}H)30MGACDp8mKD;IT&Z9~!8X{?hlb$kpyKRynZg)4f(i!KNEIBHB4 z&Ra}5d_v8cjVHu>6!QfYRd7PhnW|0*f_0wYg;8ZM$WdL!py@0@d|3e!8vEX)~AFtC>%GKJ;DjK;A=} zQ}|PV<8(GXH8pj9>DE;I>_Keh&Woj+FP3k;xOUsT*QuFT?wl{qUMGLXmd|d)P=*>8 zFOt!V8du5b{Ml-3an*28Ap+-f$t&b^?riC8ex$z?!wn`z5TO45@yB-F(!Wej3EF>) Rzer9O{+s{9g1n27{{u&^!x8`h literal 0 HcmV?d00001 diff --git a/lib/__pycache__/dep_obj_parser.cpython-310.pyc b/lib/__pycache__/dep_obj_parser.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ef5000c03bed03e32a8f87473ef612a3c9aba3b0 GIT binary patch literal 9628 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 ver@= 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 literal 0 HcmV?d00001 diff --git a/lib/__pycache__/element.cpython-310.pyc b/lib/__pycache__/element.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5de289d5fc5698221bb547122241b23fc5bda426 GIT binary patch literal 43630 zcmeHw3vgW5dEVZAW3d215PV6Bd__qnAw!U(q;^8d8bJU^K_USf03{Q)yjkqM0G0%H z!E+a+up;~j6-RO2cAaUaX-cL}QYMc3juSUYoku%O(&o`eI=M-jmz#_;&7>Von+~V3 z`+fg8_pvV$WaQeK$&xsG@7;UPJ?B6F`QPV1^?`vx0-tv*{^CmcKPD1?%#-*hjgzPG z=lvQAC$W@pEGM~cZ6ueH7LQZwsil;Yb~5Yfjm%O;u4UJ=8@Z*Nl{lGja!&r$gp9-JR^4&nTeoDbpruyYU2?~(JpPHG`BdT;sLQi(*tU3WL! zT4Q{3yD+iUSgR}Vxcyw&tJmz)J!Bt+hUOR4o?fBaZg;UjvTk~AU-l{pcqp{}N6Px7*{B&fruwCD>H_F>~t=_P=JQvSbJ$t3P?%M9l6?d~?S8H~qzOlJpE!QfpeYx6L zlXsXJ#tY|61@+}d8TXcP_vW@;Uy0vkmm7tGjn7)6v3dNl$1Y#KJYJSYj@Q-dW9zyL z-ead`CZ}f?rXNK^3x%__b=UK3g-CufEc_Tvl$usd^1nUEXT6z{j-RMo;56U>M3Z`^dzCJ+pAcer94}W?`)G z;>_Z+b7vRr7boWDCuSFCrWfqFd3$nhc4}sEW^NW|C+vyYbN2Hyvr}WX3-DkN+?O{M z+l~fS2|CvqFD$qOZa6CT3V!6dx9L`@E7b~mR9oFDue$bX{gSI{=!m`Ps*S40!S>KJ zr?6h#s5Z(CIgf@r9E!qZy{5ZM%o2dll`q)yRR^`s%5&v)`&4D;Z7*`{H|ow7;JaB?o{P5vIXs}H4LEL98U%Djxj;(%vZ~6PYaHqF`Z}<$+7P^v zmfG@iZ+m08jsdgRtF;Tjsyf;{Zrcl0Vv^%-y1>|4WgCN_+zJ|0#S7QUmryqy98`Dkd1vc2#)mj~xG+y`uC`@A1 z^3!L^jkVb?So$nmsjmZYxIV((l%o1d_05J?;Y0CH5+_gN&$Cgui6sj}E9qDuQb{NE zYGNr(VukOFlLZOL`GeDX`kyGXv)j%0PuP|9GSKAmS{*a5TJsv5V;*J@4h7uiiP&#M z8_rVzMc_{~S=h!9Z8!oJr`#xy%TXnTW(6M}v%Qn}^M+7dv92Uai7QEzmSdrP$?~@W z4y{eJfl#_7bu|w3aPeXS5)E+dvInB!pt&;ZYi{FmU0rzDU`1oC+|c91XRzS1>5gm4z}wEK>PA^r*SGB%yS!mLb#_h&l2^wir(QeS&w#wV1Y59W*t-?QIzoU7>BS*H@3TSVcoP zq8p$VnEGDS;@Q!p-yeIkpTg~nkdU5mb2;Hxi0m_2$<_q9y5qhTBwyby|sD+ly>>Chza@mQmINzFC9Z29vN`9Sg@K0Td`m~xU0fDf)8Jt z#UH1I_gg4F3~X|&!wDyOII)wyl6F!Tl4^g0mJGm=4-hCx! zuI$;#U&+gryt$Iu>ATX$D;F0pK7a8U)1oZi?(n$L=j5*yuB5M{ZQpE-_Lbj)$#c34 zrfeYR*~fs!)k{F+6;U^*H53(WFwQiyq<-8(x&zBxnUZ z+^`Mkfv1(S=Et0Lniy83Dn+ubi-|w3ALjy#XAkCQ|Jpjp=*uGdJUv<1=zh&BL5x}6e#f(V`SbXP`2<{ zKOelq?+Y8@=YwbcK4GZ*rI{Re{G54ywt3V5Rg0V&KCkQ$BiJ@_{MA~u={lqRen#LX zR8NtW`B}&)Aaj1!+gbq;_6re2g+Tggg4Hjsx{Xq0t-9{uZod$L&Cg;~H_DCCg6d;^ zDPWABCZPO*7F_W&D{Pr>ZTcy9z2WB|5N`lG{C*cBw$-3O>iI)No)Rt-!-zCOnRU_6 z`imPNA|=z|cjAgSjw1P?;{JSc#L6f0>0)XmmA6LHS*w`JCW{vT^2y{ElYf=|t9&7y z8ca3sHMnGii1B!xv!hv(^jdktEtUL2sib8&92ZNai(r-Jmt3jj)GMWuD&qa>K^COu zv4ZVYr0)FDBcsVD(iR%HN3K?Iq2-gp+0*zFM$jq>Ev4PeQr690%Q>m*mX&bxS}eM1 zE%o6z>*QWd;JAR}ywfMg{WvZ-{c>EyanTu&V`y#89tc!}&XBVg-+P>4XCJ?RZ=K*{lbdER=;`@;Ekn=FU z4?Aylj^g_s=a@5!?|Yp`oVVfoK4%O9(xZOPz`p!2@GqL+l!y$KG=pnoBEx<)^?w2r z5@?P8oS4gHP<1W+k+e|YZGBcahM}?MwV;xC6K2xIEf+FU6~Z$Z4wMT~jUa4d5rN4npr@ACp+C7$>>VS? z0cA8${Jy!`iE?!vEU-f26?`~x_&13^Zv{m)u>)y|lGKwTIXTHE6AegDPUj? zp~!2|n`+uh8InRIuiGMdybP5dct*|Ii`W!7{z`p)y?&XGM|arA08N24k&f4?c~=^v zHgs;*2Lph{?o9{+jWt!@T7|3~A^@MdM0O{F0@OI*yh!viA*hUK?8U5rIOMpLq^e+F zd`{DN+64lmgqd0ar(>f-Z3F^+9a?vTswgXG4GR;er|nr5&%h8cIdNuY5oo-Wt@Rwrgu{b#<-Lc2ORVpx-Hmd0WfU z*;c_JYSQuOVT}rHBF2P_l9txm@FF(2Bw#|@G-i;{xtu;4f@0q&uScR7!~)YSzYsOW z&n&N(D;NBs=#mCy%N6OdpI!zk_aTY^HZB}ObFoaD^BV{#%o zkj&jTD=lFUC~|92KAQ7heM?o`QW+xubEHC1;V^(g1iP}D#}8q%|Kv(x@?Ct&={)by#v34gc~E`DZV?$peb zKiCRpzaZyAy+@Nm6VyBK)=@o+&Cfy&)DyVgK9vu|=b}_46Zyw*tDeXsoXDKQEcuOz ze7}+GS|)PzaD|4+;6tf_f0PtnCw+ot1%k66IpD-0-Qdu6>2FG9ey{}cegXXI&faLycjPCb3Yg00N0 z-AIC76Pb-V=4Yg`wsBL`!+sTiuTvK)_UA3AIrvLQRxlYHU6a(%F)ArM$x#tIuw&_Q z8G#+_UOh4?jLcwcG=3vK8Uh#NEs3l|UyUQ8c_*z|?Srw3&f_!wv^kgw4xV~ioyHHC zPiOGuXLS|rL-39G?f38}dghP}K|Au*l*_C$5{9Qvi6XK{)J zA*$wBL`u8p>&J2J&v|_wMe{(MwnS1%iw&2O5C4e4-g{8c<_fQX)v(SdSLho^RY7G+ zX~%jkaVeqpzXnGvgacgp*2D^E4*VSXM&`A|wd{4FMQhd}SYfZyw~%XOuJTT(`=CLY z=3h)kz63F+K^rqFZ$u6{NsCw#wayz;?W6*cIZZD^!axF{ZH1&|_2u*MEP<@S$_Q5l zd|k^B?Bzl8Zs>^PtK-^%6zJxB!ylZ}+VWH!O88?$^I?to_21)SqC7+I;E& zxP-?^a3IlqC>Zp>WIz7odc6X*^r`Xaf!WbMZAB9)H@64_!Sn96pDsaWmsa~J&u#c= zitv6RQi%M%(2RB?EhaN{4_hToqm4+n?*$$SLXxC@34h)oifjQktyDf)u#Uja)f{SX zX$z>x_=0);I?)`i06{&VQnLBoMsMW^&rI2yb?9qgUP47-9WXT?^{9kIxYd=_Oc-_- zS!cF9?Ncgir;g}0u2@0%g0eQ5(2^NxG>Xl|7@gB!G$uxaD4_6@Rp(yZ^*TFM$TbT= z@2gJFeybAhMQ=YH>+RyXGt+Nyf6IUX%ok~S(IHAHLxiRvNBz@s(y!u0(7%+%EMEC&fR2d;fBFT&@~ z=l~3&w&ZZ8AFGbLpxKYk)L7?Y{p&>)G_=Iz$G&KJ z))OrTmRo(D1|o!dpmmryXg2x*D-|G2%Bh(SZH^n|P5v6Tg{&lMZv?0asanv06M%TX zKs2CDV2x}eOSVBo_*ld=5V?@TtAUJE^L?w*j*NXz0ty*FV*w_{hnLL2 zi*b+O!BI;cMCD!Q_TG*P{#JnS!^P&|p08=?E}n?yJ6#YY{lIjq{J@=IdI$Q&Mt#wk z;y2uKO@dl5bd0)DRR?5_qeZ(C4aMX!A<74%hGAA!dVOM5c10v=GG^k1m0-YIs1pS0 z&Zdgy8^SO1pp}VFk5}C>xqNFfZ z=2g*@>B(_a70o`4UJJTsbItw$4}swwouzyx^p+FN!2|=fwXU;FUQb-7U?^RBb#FmJ4t&%&h?|U4c8I5ka7teP!pk z{mKq37KhPz<*-<0QD)J`hv5SRO)HgN2=HMf*W438#{AX+9g-q}4lARci>MBAA*=bL z0Zfd<9d_hyV8U6$3N$FimMoYN@hpV+iI)qGErDC7Q_KL+^eCDhNk7f1MONzy7&uUQ zL#b2(WZDFj;HM72qyoTDFS zc^Zs`wVIcvwW|!%23ib)K5j=C7S$F)8d~QSd(F^La9m71l5Fu&K4G7Lx&sOU!mTbN zY9iMAv|p&gQLBYPvGEHa{K46JV+L`W5}b=z10&33q>lE9lP#6pi*TnCK4edY+z?H9 zL2w^}rTB9Tga{qZ7@0aJaL~+>sZr&75v(npXjt1vEI9rw2*daV4nB~cG+lP|n7^1f z2V>Gs@&mBcFPNjt)@2HR6ewqCwgWIu>p;x~i5!HV5o9?C%Ag3c2~BkjaX=)`t%@<| zAa9^Z78{68xfu6p$(SzTU~Y!5fFl~gNf-ttPQ_SFJu%wQirD)h7fJKXw2gN`>K|iB z?JPacCgO$9R=sfYK(Z29)Kn8RF2aH4==1CaLQKd_+HMO;H{Ignv!fZU4d?NQ#My|} z?B@_!O!ufzSrA)M_sC@Yj2Hph5k@o~nk06JGE{|={YBV+hiMD`Q1Xk>^upkjcwjZ)aR_UKltax4V#pMGYgZJ+ ztY5=8MzuB{o_9Clg``a>LXS~c zz2V$KL_jV~L`JNZu7gDuz>3fwTE55)!vVh)&9~J1I1q-Ur`d}3At<7*Qqa8X0GhVa z6e9UJenZOuv{3fv@S;x2-hd%>8^(^1x~HzeoO(^dGj2oOpFvx0H3v0Ohgy9bsvc;q z)K|ONI79N;IewTYo+N@e4Bu zT7Oy7|0YJb#}xl&{&a;!o1iV>hXHo|1SfgG0)`_N5E_HX0)m@oN54ir#2O!F@m3Z` zSS{^tC8D3Uut>GLJ-2SmRNR zg{b%BXX+vgkHr>?Z5BH$KFs1Oi;u8~dMrm``=xD}^wDDj7(AHnPg?~m3HxvILHybH z+l#+F1uMTde_y^2|ML04d_j(QR>bvv#iGb*6;#slNt-i|P3{ahbg)oNLI$&L20?)g zGK8kGloPK7!wmCFCd6>5Plp#S6-;npzX>iZn&83#6I?j>tq|0f_CSC=sA!C9o}FN9 zY_!0IlOb*%78PD%x+QRx6jkEZKvn}RM--++>Wh-3`4GZf88X+RVUWbL!;|=X{Sg!m z=ogLTc{ppXCwHty`h13GaLm9ROksd7V8mr;=kM=pdWyD@aKehfsJ^yn9^Jwy3QEVK zbPdYrq!Hp=5(nuU95=ALcp~++fQWjW#n+)2&1%VB5T663=L_NI?=!3{7OCaIS~kYJ zx;gcW@tB-n;@qX1W3V`shL1Se92T&R$8VbLIdC3ZcqQRw7-F*{QG7hoL+FSHAAvO( zN{RHd7>pwWm}&mbT?`rDyIwm7Iu(_q6u$fpajNwWC-fawZT!#c>{5D6BB38OTYYiszDG9PN!<0af$v(7ni7>iGkVT7Mxieqg3TOougX9isV{K%aKnsMg?T~O3MO2F3}K+ ziXjl5UabIGFton6*4yqA)hn4=#Oj;rS9V_6@kfHy03{QY!#Ly4tK`13qXj?XZ6Odd z#-<`%IEP_C)W3v7XxLyz^h=-%q}2=c5-@wOR4S4u6=$BEPlx@N&pv&rVcsJB7e#1GZc;m}Nf1P@Dx2U+Tg=>WAH zXkFB>JzvVD}u280BFyq`6+l_wLDwKX-tY00hSHFCv2Z}z{*+y!{=}X?d!nw zz*h76L#W`2bzbZ;SFIJRsw4Lk*V6DLSf8}M3(cc1`$qES#zeb|Ij~YvsC>g+)}t9e zPp>ltH>KG3HZri%1s-d-_7=cHX8uFj02VFYYy2F3IO79Yc<;aoFjD;1FO6V@gLNJP zEaMkZCIOu!paaDiY%~+V=3?4`9pi?DRZW5{y3L#gu{LvRiGX**7~)oW;R1`cNj4-a zr398T*Sw>_;1;d7TInwZq^$im^$$_m=umG>>@~hjNT@W197g>Bi)$<*m5KmVKaRp5 zFr2C4V!eRjPSV=%=PiPL9FU(QpSlkRqCTZWbs8yJST42)s!~5xq|8uWhOUB}T0R6m zKi&sWbgDX~N&R{y)>6@^NxNQyCF;^XkoZID02HH9|N+ZMMTmeEz)$Xaa!4ePc~ZEt*)cqn}hFv-@@CT#^3wKQ9RT_Du|Dv z;`9wT=~SE~o(<$eWbbUKMnQdYV1VZe8A_8lx*XP(TB$4NPOeXux~8e)iTvbmSw zYSEs058g36+B$f*RGEHz7lTDg5*sWzjR_TyndQ?tQo@M8QZj{_B7~_asqbcC6eYq( zt4FOvgVnD{kWvfQwR|scYa1R?t&^mMc^nM(=V5ln;OvpXk*;c`h-^Ce@R%%^WX5|O zMNBEmh*HEj(&Uvi%2WgM=&A)1MfQA7RHQt)9`a3m1L)f=6davzVNq*1nqJTZ4W%J` zhy$b*8{XET$nd9_(IXO0_*^Wvg0^tVl1x^w1L0w=VU(Yz^E`kJQ|^JweS?W*kk$q1 z8lGrr2rFf1la2-Av$BF5);uh318u7z8A-HUSeyxBVUKEUZJ=mYCNYG`6%pOQ7|!nl z;I)24KC8$n^up*gZR#5D~x1s1>vTful%9e;?g#CqCD3jVn>rt2k)x3jr7-JfITcCoWXJ z{v0lg?0PY@^FJj^ndU33J0Pawpi5DGr!7g*2UiaM93>Vl!SHMzPY6us_7cZ*l zFP}8tar!dh4LG6$u#Q9lU<@@BACxrI2Gv2R0qFtO955q5Aj-gAqN5Dr5#e3YmsG{P zPR2OUE3_4cRkx?cFt0N1FW#Za3KJWV2Jv&FPpddNb2D-ygfZ$KOB zjZit@cw)K1ec@>GNJbK)+_wXVHvDIZkVv8a~0>6<}OBg#X9eyj1uCn+D3&AL}R^Xd) z!MD7w%GrM6NHA2PZvAnfl?aX5{t*OAAkM~W-Xk-w>+LP7cnfaP;T7PPV*#`71wMg3 zT*tZ!90B95TOnqpfLX?RaB;6-R;v7gE|}#Z`UvUEVCwvG;1=n^#0muRcZn47j0@7^ zz6;WfnUH4syC5^mu*R%uO^W%^n0q9K5?}487J)Hxi-nzxQM4pHV}ysF)v(aGm)5w~ zigD_5#JyHA*FQ*{iCz{W*(W0;V?>T1*?l3Bb$Or#&G2w&x)24VDK#sEBGB0^L#ootTZy`v%vSK zKuHtuQNR{}vv=$ywK3+3h2WxR9cw4u$e-^+DlEjE9frwDz0*k{v6pqe-xzIZ(#l*b zLd@59B(+hin{h|lfcNM-EDjW_Jr`eMy1pB^=4U4*(IF&vSRFVWDU%@fnrwcvj;xH!Fi_Oo z%aV4)8;zE1#(X7V42^IE0wjjt*)^{|#OdJiW%FpJ5@&q)_r-;3*N<-?QiGB| z_tsFZq|S)@fP0-B>p|($h}oCzUIin>N9h z)vvQ{ud(Q9xuamC1OV)r%uzAlsZa4Vn!%CLj(~a_`(ooj8|@I`z=(&Df>j*O4@*?T zFyb2a!f?mCEBI^qq|BN35gdGF40l2DBr)KPKrp^X?MGvpzpvA4X{}g<-lJPE;7CTW zqq1-)CX!MEMM_$xMdIAyDxJW17iOU2oRTQ8Ft44nCHY;koW{(NMG>$?4-Y~XLM{c~R^LUaPNM+zWZIcdOJ5t@{}~_XW#Ib~K>j+-JcC6Ed$5}OW1x>| z+mtSX509yzktlgjq3AI4NfeS8_0qC*JR>H*EE07>zvc>v^L^(FFaq{#lV9_r9T5+) zG>CSfm_$ovI)zt;J%m~qd7aq*TCT(`do4fEq84759WBNzXb~)FEYtZ_LxY<3jqk$` zNWY3qyXx=Y&=}NWOQ3UnQXV3pyV=u*0ZEUXW2k`Se)tEA+P>I0?AosOvv-UBx1c^< zb;mDP+=0OR_SUtB05J8F09f!@#{&{NByu9`B*#Eo*wlZ)F-%eN(vR_aFH_W` ze4m)24!6B8Hbr_Fq||5l8msw9GdN->imHJvAT;iX>TY!M=#Jf6jldTJl|HO92wyc) zCYf7cm5N!TIA!=zKSenFOBRtRBZgnfJ>Lj8#0@%WG3X#CO4L|MixRxNWd!1?xB5B4 zBw6;lDA0kv-%Wjvy(ea66Dc~N5;18bGLePT`)>?1GRoIE!2gQH=UB82HKp6;BWBhA7K${ugE+UZbI<4mhvRc+BQFmJ*| z@3PKXN1a$AX>?tvS>Pz?fXD9-AczR1xKtP2F}lB}TCDyP`}?0+{B?BV-(dwk6=OpD z4>88x5gdqOoav<)=lg~p6h>Xatt}to$kX`qz8*!pYODkPvDE@LTX;3Ggn)nU2D6mY z`@kUBzaNTh(eF1L@f5P_HU9{!z4XFu*m$5`Y7dU=QbD^7V&&S@NVyv7pTdl&nldFU zyv1fO2j&eB^-@?7C=)F8CI=?$MX=z&Zo+&KOz=mqFpNni+eNuTM=`Pp$x4F2=wy

i|BdfQFwfsRQG+|i+f$8bQ8SqTAa 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 B`P={i literal 0 HcmV?d00001 diff --git a/lib/__pycache__/file_handlers.cpython-310.pyc b/lib/__pycache__/file_handlers.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6a727b32554f8228e7f004a6684514e1dc99e595 GIT binary patch literal 4984 zcmb7ITW=f372aJgFIrtJDXC*8K|593*g}+*lN13e1Vt1{wwc;gV9F7!7h<_Hlr~!K zZf1v~Nl-xnrH@7X)&hY62Yu>qXy5uD`mnEk@?U6ExZjysGKx{8DFqI9c4p4~eCLeP z#YNA;=e>i!_5S{nW&MLDmp==be1LEMdkowU}YKK6|rn~aHqGxSNTiWvBI6Uwf=I@aV^X1MX~6d1PPCY%>8WSZ4cB* zD)TLNKROlc?m#Fd;tlpukf#Z|@3WmiDj~hyK#483D?-ul3d!EN_0GHACm<1}$rgLt zzvX%NqEIBc;B1g^>`*6yZD&DyB8EL5?u!Lb^B@ zvQ)C*SPHDEl;j-BNJYu<2Fuf)8U|8$Jj#`fx&!qs`;@XXXnJ`A3?C$HZM)5y?KSq{ zcDvc$@D7^?clRG2u*2;K54Kwe&Am3;f53M3Tf5DJ=6(ya+ibh_i2bVB+TCCR=7AH$ z<4lt55HKRs3GRDsLB^eVh4rvV^E?w_)QduBlpGI&W5JHoGa(Zw!ZIQIQBKazAsYAM zs2{07>G?PAp1H!?NfVyFG`dkpRdqFpqflB4b^L9lZ$|xg>TZ|pd zd=JZn11UwK;t>K1fx$Nz%t$7S5+x97+US`~&mu0!>)0znJx)W-Nb48Gv6e!|la799 zwTVL^tV0T>(6H2Jcp?(kPo-eJK@yTgK^&$HC+SeDiA!?;R>inaF++L}g~CdeP{I0Q+`?ls$3Q77;vk!PpyQO-`_ zC@dOZ_?mQU|6p&6-4zJIAtDAp%{#6A2hG+U=mC=OVF_!<83i1|y!$D5=ZME15++w^ zzYHJ=1wbGHI3ZKAI0%jSa9p1ueuN*AeWxFKMwzg7@Xc>wP}am&_Jt$olrym}?6!GR z!JF0D8~15-f_-06ov~Z%wZe;#Rkqvy1V&eL6eIh@Fu=F zRcp^~*z{en$1Ah&@JrS$1WlvUNrJxUbPBK2>8E^v^4KXBI-So3L0qoXI~|^eosK50 z1-vw@fQxKk_!;r6VQ5&cy-?Nfia8*b>|_Yr3m2^ObGG+D{SxlgeyfIkX&$S6sO7qGDTzR@-{p z%5My@m;N5%dx-Tm)|ESYXj|v5ZH-?8o$AE>(&FxnE_{i3)y!3m*DgTg5EOXj-2L1o z8s=$egI0YsDQUg_-)Ql=rq*~;A*uf-wZ`aA-$`vwQ}ezLwV|~>Kh6$i^?oFS_K$n>5f z(I5Y~JqW3)^ae3doQt=>I6?&(Vmv?k+ zB0h%Z_ydMQi24_ge0Do}tfefA4s|h9f+2k;ZTr3&~bc_QETf6`S#T6=Y9bM5obmCnQ$bAe>g!lh! zU9}s(UUHW0)fb+ef6Jc|<9VVmcl2^nK+Z`JOHBH^Cs<~ zH)hwweQNXC#39I_{k;C9s#6{3X78LQ?gaqrD@#6_RCwdm!LR$&y|iQQiK}6asCo26 zMSXl-0gTjT>Y&8uFC4z0>U{CHfH2K}iB16Ca0Zan82tetQUknQ*Ia6l<4Or{;L4Z3 z%}GO>dtUpzM(4SH($F?H`pb9HS@}M6h9sfOSG8?kGQg!}o@t4z*)LEM4*($$uWWPRI(ay+#8yfbxg5>&nNoyRs=IbxEC}e1nD? zG~A@YRF<63yhCh$LNBTJH3M~(#i}X0B_3$r_whb|A48+D;4D`b>}&Rtea&IcRkXRT z?Ko?wD^ycF{4JIBW%}%Vy;`ST@;wk(UzI-~CUvx>$55tlDSmWsZuHW{birJN^leR= zYFQ^=q4PFIsQ?#DDJ2u2*;jPmL$;X`=5}#Q*8DChr`6ONHr_Wa_b;h6$yYJBR^#=T Rodq=P3-)s5>e3sp{s(uC$msw8 literal 0 HcmV?d00001 diff --git a/lib/__pycache__/file_handlers.cpython-312.pyc b/lib/__pycache__/file_handlers.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f51b14e2e1500f701970352c67a7f1b448337b71 GIT binary patch literal 6614 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 c`!UgZ0(khNod*`lIGnbtI1p;dH761SM literal 0 HcmV?d00001 diff --git a/lib/__pycache__/grapher.cpython-312.pyc b/lib/__pycache__/grapher.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c4cfffddebb95144ecb93f1da6702ffb0de3e473 GIT binary patch literal 13156 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 literal 0 HcmV?d00001 diff --git a/lib/__pycache__/style.cpython-312.pyc b/lib/__pycache__/style.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4def0b40886ba480c0239a22470cbe1fcacc29d6 GIT binary patch literal 10393 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 RG5l>{*ekX@5UAvh{~MAze4hXS literal 0 HcmV?d00001 diff --git a/lib/__pycache__/sysd_obj_parser.cpython-310.pyc b/lib/__pycache__/sysd_obj_parser.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d890f8e535e36a144597dd26653316c938ec03b6 GIT binary patch literal 16316 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 KHnTN%`F{Xs#`-$| literal 0 HcmV?d00001 diff --git a/lib/__pycache__/sysd_obj_parser.cpython-312.pyc b/lib/__pycache__/sysd_obj_parser.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e097755f3a1b91bbac32dc7afc2413db0ea79e2e GIT binary patch literal 23998 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 QAdZNx=HCl+W?k@q0AR8DJ^%m! literal 0 HcmV?d00001 diff --git a/lib/__pycache__/systemd_mapping.cpython-310.pyc b/lib/__pycache__/systemd_mapping.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b54f9544528e73ab0db04f7fcaf95951d691dd7d GIT binary patch literal 14210 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 literal 0 HcmV?d00001 diff --git a/lib/__pycache__/unit_file_lists.cpython-310.pyc b/lib/__pycache__/unit_file_lists.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7e7b95d1e9ba94ce081cd56fa67fc4a16686cf4c GIT binary patch literal 10420 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 up;C{Qe;AAa literal 0 HcmV?d00001 diff --git a/lib/__pycache__/unit_file_lists.cpython-312.pyc b/lib/__pycache__/unit_file_lists.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4c5b066d34a2123af4de968ed0959c29aa29cf31 GIT binary patch literal 10760 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 None: + '''The DepMapUnit class creates the dep object shell for dep map units and uses the + dep tup that is passed to it to create the reverse dependencies for the units.''' + + self.unit_name = unit_file + self.parent_unit_path = parent_unit_path + self.parent_unit = self.parent_unit_path.split('/')[-1] + self.rev_dep = rev_dep + + # Create an attribute for each dep and rev_dep in rev_dep_map as an empty set + for key, value in self.rev_dep_map.items(): + setattr(self, key.lower(), set()) + setattr(self, value, set()) + + # These are all inferred by other dep/rev_dep types so they aren't mapped in rev_dep_map + self.parents: Set[str] = set() + self.reverse_deps: Set[str] = set() + self.dependencies: Set[str] = set() + self.commands: Set[str] = set() + + self.set_rev_dep() + + + def get_commands(self) -> Set: + '''Returns a list of executables for dep map to map out forensic dependencies''' + + return self.commands + + + def get_significant_attributes(self, mapping='all') -> List[Union[Tuple[str, str], str]]: + '''Return any attributes that aren't empty. By default, all attributes containing values will be returned, + but users can specify only forward or only backward dependencies to be returned as well. Mapping options: + + - 'for_deps' - returns a list of tuples containing forward deps and their corresponding attributes [ ('key1', 'attr1'), ('key2', 'attr2') ] + - 'rev_deps' - returns a list of reverse dependencies/attributes [ 'attr1', 'attr2' ]. Possible because rev_deps are all lowercase + - 'all' (default) - returns a list of all attributes that contain values [ 'attr1', 'attr2' ]. Discards all methods and empty sets.''' + + attribute_list = [] + + if mapping == 'for_deps': + for key in self.rev_dep_map: + if len( getattr(self, key.lower()) ) > 0: + attribute_list.append( (key, key.lower()) ) + elif mapping == 'rev_deps': + return [ attr for attr in dir(self) if + ( attr in self.rev_dep_map.values() ) and + ( isinstance( getattr(self, attr), set ) and len( getattr(self, attr)) > 0 ) + ] + elif mapping == 'all': + return [ attr for attr in dir(self) if + attr == 'unit_name' or + ( isinstance( getattr(self, attr), set ) and len( getattr(self, attr) ) > 0) + ] + else: + logging.warning(f'Invalid mapping type. Please review code to pass a valid mapping type') + + return attribute_list + + + def set_rev_dep(self) -> None: + '''Check to see which reverse dependency is being passed when the object is created, and record it.''' + + if self.parent_unit_path != 'None': + self.parents.add(self.parent_unit) + self.reverse_deps.add(self.rev_dep) + + try: + if self.rev_dep == 'sym_linked_from': + getattr(self, self.rev_dep).add(self.parent_unit_path) + else: + getattr(self, self.rev_dep).add(self.parent_unit) + + except AttributeError: + logging.warning(f'Invalid reverse dependency: {self.rev_dep}. This does not map to any attribute sets') + + + def load_from_ms(self, master_struct_unit: Dict[str, Union[str, Dict[str, Union[str, List]]]]) -> None: + '''Handler function for when an entry matching the dep unit is found in the master struct. It will + look at the file_type to see what type of file was encountered and then parse it accordingly''' + + # Disregard remote_path entry + if isinstance(master_struct_unit, str): + pass + elif master_struct_unit['metadata']['file_type'] == 'dep_dir': + self.update_ms_dep_dir(master_struct_unit) + elif master_struct_unit['metadata']['file_type'] == 'sym_link': + self.update_ms_sym_link(master_struct_unit) + elif master_struct_unit['metadata']['file_type'] == 'unit_file': + self.update_ms_unit_file(master_struct_unit) + else: + logging.warning(f'Not sure how to parse file type: {master_struct_unit["metadata"]["file_type"]} from {self.unit_name}') + + + def update_ms_dep_dir(self, ms_unit_struct: Dict[str, Any]) -> None: + '''Parse dependency directories given by load_from_ms(). '.d' directories + don't create dependencies so they are not included in dep objects.''' + + for dep in self.dep_creating_dirs: + if dep in ms_unit_struct['metadata']: + getattr(self, dep.lower()).update(ms_unit_struct['metadata'][dep]) + self.dependencies.update( getattr(self, dep.lower()) ) + + + def update_ms_sym_link(self, ms_unit_struct: Dict[str, Any]) -> None: + '''Parse symbolic link entries given by load_from_ms(). These are parsed independently to retain the full sym link file path + creating the dependency, since there can be many sym links pointing to a single file from different places within the filesystem.''' + + self.sym_linked_to.add(f"{ms_unit_struct['metadata']['sym_link_target_path']}{ms_unit_struct['metadata']['sym_link_target_unit']}") + self.dependencies.add(f"{ms_unit_struct['metadata']['sym_link_target_unit']}") + + + def update_ms_unit_file(self, ms_unit_struct: Dict[str, Any]) -> None: + '''Parse unit file entries given by load_from_ms(). This function is creating most of the dependencies for the entries in the + dep map. This function is separated from load_dep_map_unit() because unit files actually create deps, and loading previous dep + map entires only needs to copy the entries that have already been recorded. ms_unit_struct can either be full ms_unit_struct + or the metadata dict within the ms_unit_struct in order to check for implicit dependencies.''' + + for option in ms_unit_struct: + try: + getattr(self, option.lower()).update(ms_unit_struct[option]) + self.dependencies.update(ms_unit_struct[option]) + except AttributeError: + # If current key is metadata, send it back into the func as a new dictionary to parse implicit dependencies + if option == 'metadata': + self.update_ms_unit_file(ms_unit_struct['metadata']) + elif option == 'file_type': + pass + else: + logging.debug(f'No set in the dep_to_attr_map matches {option} (from {ms_unit_struct[option]})') + + if option in command_directives: + if len(ms_unit_struct[option]) < 1: + continue + + self.commands.update( ms_unit_struct[option] ) + + + def load_from_dep_map(self, dep_map_unit: Dict[str, Any]) -> None: + '''Companion to load_from_ms(). This function takes an entry from the dep map and records that info to the dep object. This + prevents duplicates in the dep map, and makes sure nothing is lost when the current dep object overwrites a previously + recorded dep object with the same name. This duplication may happen when dep tups have the same dep unit, but different parents.''' + + for dep in dep_map_unit: + try: + getattr(self, dep.lower()).update(dep_map_unit[dep]) + + except AttributeError: + if dep not in ('unit_name', 'binaries', 'libraries', 'files', 'strings'): + logging.warning(f'Could not load "{dep}" attribute from unit already in dep map. Investigate {self.unit_name} in the master struct') + + + + def create_dep_tups(self, current_item: str) -> List[tuple]: + '''Checks each dependency set to see if it is populated. Dep tups will be created in order to map reverse dependencies to + later unit files. This function does not deduplicate anything. Instead, map_dependencies() in systemd_mapping.py keeps + track of whether dep tups are duplicates.''' + + dep_tup_list = [] + + for key, attribute in self.get_significant_attributes('for_deps'): + for dep in getattr(self, attribute): + dep_tup = (dep.split('/')[-1], current_item, self.rev_dep_map[key]) + dep_tup_list.append(dep_tup) + + return dep_tup_list + + + def record(self) -> Dict[str, Union[str, List[str]]]: + '''Inspects all of the attributes of the dep object, and if any + of them contain info, will add them to the dict that is returned. This + returned dict will be recorded in the dep map, overwriting any previous entry + with the same name (same unit file). This prevents duplicate entries.''' + + out_struct: Dict[str, Union[str, List[str]]] = {'unit_name': self.unit_name} + + for attribute in self.get_significant_attributes(): + out_struct.update({ attribute: getattr(self, attribute) }) + + return out_struct diff --git a/lib/element.py b/lib/element.py new file mode 100644 index 0000000..0fc4216 --- /dev/null +++ b/lib/element.py @@ -0,0 +1,1571 @@ +''' +element.py +Authors: Jason M. Carter, Mike Huettel +Date: December 2023 +Version: 1.0 + +Licensed under the Apache License, Version 2.0 (the "License") +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +Contributors: + Oak Ridge National Laboratory + +Description: This module parses a master struct to create a graph of all unit files and + symbolic links found. Since dependency directories have all items within them recorded + individually, the dependency directory entries are ignored. +''' + +from pathlib import Path + +import re + +from . import colors + +from lib.unit_file_lists import unit_dependency_opts + +class ElementFactory: + """A class whose instances construct Element instances for use in graphing Systemd + data. + """ + + def __init__( self, remote_path, log ): + """ElementFactory Constructor. These are used as nodes in a networkx graph that + can be rendered in Cytoscape. + + Primarily I am doing this so I don't have to continually specify the remote path. + There may be other reasons later. + + Args: + remote_path: the filesystem prefix path TO THE FIRMWARE root directory + log: logger for messages. + """ + + self.remote_path = remote_path + self.log = log + + def make_element( self, uid, data, master_struct ): + """Make elements (derived from systemd artifacts) that can act as graph nodes. + + We use the term Element because these items could be converted into nodes or edges. + + This is a generator because multiple elements can be created from a single unit + file. + + Args: + uid: The master structure dictionary key that is associated with data + data: The data dictionary that is associated with uid. + master_struct: dictionary used for libs, files, and strings for elements + + Yields: + Instances of Element type: Alias, DropInFile, Unit, Exec, Directory + + Raises: + ValueError: if the file_type in the data['metadata'] dictionary is not + one of sym_link, unit_file, dep_dir + """ + + ftype = data['metadata']['file_type'] + + if ftype == 'sym_link': + yield Alias( uid, data, self.log ) + + elif ftype == 'unit_file': + # Split out two different items in the graph from a element file type: + # 1. DropIn "conf" files + # 2. Systemd Element files + p = Path( uid ) + + # p is a file so look at the parent directory suffix. + if p.parent.suffix == '.d': + # p is a file, p.parent is the directory, and p.parent.suffix allows us to detect + # whether this is a Drop In path. + elt = DropInFile( uid, data, self.remote_path, master_struct, self.log ) + yield elt + + else: + # p is a unit file that possibly contains directives for working with service processes. + elt = Unit( uid, data, self.remote_path, master_struct, self.log ) + yield elt + + # This is ugly and probably should be refactored with a more elegant solution. + for command in elt.get_children(): + # Unit children are Commands + yield command + for executables in command.get_children(): + # Command children are Executables + yield executables + for libs_and_strings in executables.get_children(): + # Executable children are Libraries and Strings + yield libs_and_strings + + elif ftype == 'dep_dir': + # dependency directory information is not needed during graph construction; the dependencies + # will be derived from the Systemd directives. + return + + else: + raise ValueError('element file type: {} is not recognized'.format( ftype ) ) + +class Element: + """Instances are single elements in a Systemd graph; this is the base class for all elements in a graph. + + Elements are uniquely identified by a pair of strings: ( id, type ) where + id: the unit name, directory path, library name, other string + type: one of { ELEMENT, DIRECTORY, ALIAS, LIBRARY, UNIT, EXEC.*, EXECUTABLE, DROPIN, STRING.* } + + This key is used for dictionaries, node identifiers, etc. + + We can look up Elements in a dictionary by their key: ( id, type ) pairs. They have hashcodes and equals methods. + """ + + TypeKey = 'ELEMENT' + EdgeDirectives = unit_dependency_opts + EdgeDirectives.append( 'OnFailure' ) + + @staticmethod + def get_default_vertex_attrs( subgraph, node_label ): + """Return a dictionary containing the default Cytoscape vertex attributes + + The following attributes (keys in the attrs dict) on vertices are passthrough: + the values assigned here are used directly to determine the formatting of the graph. + The Cytoscape properties are the SAME NAME BUT CAPITALIZED. + + node_fill_color, + node_label, + node_label_width, + node_shape, + node_height, + node_width + + Args: + subgraph: + node_label: + + Returns: + A dictonary containing the attributes for this vertex; the keys are special. + """ + + attrs = {} + + attrs['subgraph'] = subgraph + attrs['node_label'] = node_label + attrs['node_label_color'] = colors.basic_colors['black'] + attrs['node_label_width'] = Element.get_label_width( node_label ) + attrs['node_fill_color'] = colors.light_colors['blue'] + attrs['node_shape'] = 'ROUND_RECTANGLE' + attrs['node_height'] = Element.get_node_height( node_label ) + attrs['node_width'] = Element.get_node_width( node_label ) + + return attrs + + @staticmethod + def get_default_edge_attrs( subgraph, edge_label ): + """Return a dictionary containing the default Cytoscape edge attributes + + The following attributes (keys in the attrs dict) on edges are passthrough: + the values assigned here are used directly to determine the formatting of the graph. + The Cytoscape properties are the SAME NAME BUT CAPITALIZED. + + edge_line_type, + source_arrow_shape, + target_arrow_shape + + Args: + subgraph: + edge_label: + + Returns: + A dictonary containing the attributes for this edge; the keys are special. + """ + + attrs = {} + + attrs['interaction'] = edge_label + attrs['subgraph'] = subgraph + attrs['directed'] = True + attrs['edge_label_color'] = colors.basic_colors['black'] + attrs['source_arrow_shape'] = 'NONE' + attrs['target_arrow_shape'] = 'DELTA' # this is more arrow-like. + attrs['edge_line_type'] = 'SOLID' + attrs['edge_color'] = colors.basic_colors['black'] + + return attrs + + @staticmethod + def get_label_width( label_string ): + """Compute the width of the label based on the string it will contain. + + Args: + label_string: the string to use to size the node + + Returns: + The label width as a float + """ + + width = len( label_string ) * 5.0 + if width < 100.0: + return 100.0 + elif width > 300.0: + return 300.0 + return width + + @staticmethod + def get_node_height( label_string ): + """Compute the size of the graph node based on the string it will contain. + + Args: + label_string: the string to use to size the node + + Returns: + The height as a float + """ + + height = 15.0 * len( label_string )/50.0 + if height < 30.0: + return 30.0 + return height + + @staticmethod + def get_node_width( label_string ): + """Compute the size of the graph node based on the string it will contain. + + Args: + label_string: the string to use to size the node + + Returns: + The width as a float + """ + + width = len( label_string ) * 5.0 + if width < 100.0: + return 100.0 + elif width > 300.0: + return 300.0 + return width + + def __init__( self, uid, data, log ): + """Constructor for an element + + Args: + uid: unique identifier for this Element + data: the object that describes this element, e.g., the Systemd Directives. + log: logger for tracing and errors. + + """ + + self.log = log + + # Elements are uniquely identified by their UID name (could be a unit name or path) and a type. + self._key = ( uid, Element.TypeKey ) + + # Data derived by our tools. + self.metadata = {} + + if 'metadata' in data: + self.metadata = data['metadata'] + + # Options and directives provided in Systemd Unit files. + # this could be the empty dictionary. + self.properties = { k : data[k] for k in set( data.keys() ) - { 'metadata' } } + + # The set of Element instances that are direct children of this Element. + self._children = set() + + def id( self ): + """Return the ID portion of this Element's key + + Returns: + A string; this is usually a path or the name of a unit file. + """ + + return self._key[0] + + def get_type( self ): + """Return the TYPE portion of this Element's key + + Returns: + A string; this is a standardized string, e.g., UNIT, that identifies the type of Element. + """ + + return self._key[1] + + def add_to_graph( self, G ): + """An interface definition: Add this Element instance to graph G + + Args: + G: the graph to add this Element to. + """ + pass + + def get_children_keys( self ): + """Return this Element's children as a set of id pairs: ( string id, element type ). + + NOTE: If the get_children method does not return a list of Element instances, this needs to + be OVERRIDDEN to provide the correct information (i.e., a pair for instances does not have + a key() method. + + Returns: + A set of pairs of strings; these pairs are expected to be valid keys that can map to + instances of Element. + """ + + return { c.key() for c in self.get_children() } + + def get_children( self ): + """Return this Element's children as a set of Element instances. + + This is meant to be overridden to fill up the children instance variable. + + Returns: + The set of children of this Element as Element instances. The directed relationship is ( Element, child ) + """ + return self._children + + def key( self ): + """Get this Element's key; once set this should be immutable. + + Returns: + A pair of strings; this should not change once set. + """ + + return self._key + + def __str__( self ): + """Get the string representation of this Element + + Returns: + A string that represents this element; the type is written first. + """ + + return "{}: {}".format( self.get_type(), self.id() ) + + def __repr__( self ): + """Get the object representation of this Element + + Returns: + The repr of the key pair: ( string, string ) that uniquely identifies this Element + """ + + return repr( self._key ) + + def __hash__( self ): + """Return the hashcode for this Element + + Returns: + The hashcode for this Element is the hashcode of its key pair. + """ + + return hash( self.key() ) + + def __eq__( self, other ): + """Equals predicate. + + Returns: + True if this Element is equivalent to other; False otherwise. + """ + + if isinstance( other, Element ): + return self.key() == other.key() + + return NotImplemented + + def get_data( self, key ): + """In this Master Structure's metadata mapping, get the object that key maps to. + + Args: + key: the name of the metadata field to return. + + Returns: + The object key maps to in the Element's metadata field, or None if the key is + not in the metadata. + """ + + try: + return self.metadata[ key ] + except KeyError as error: + self.log.warning("Key: {} not in the metadata of this unit.".format( key )) + return None + + def set_data( self, key, value ): + """In this Master Structure's metadata mapping, set the key -> value mapping. + + Args: + key: the name of the metadata field to set. + value: the value that key maps to. + + Returns: + Nothing. + """ + + if key not in self.metadata: + self.metadata[ key ] = value + elif isinstance( self.metadata[ key ], list ): + self.metadata[ key ].append( value ) + else: + self.log.warning("Replacing metadata[ {} ] = {} with {}".format( key, self.metadata[key], value )) + self.metadata[ key ] = value + + def has_property( self, prop ): + """Predicate that indicates whether a certain property name is in this element's properties dictionary. + + Args: + prop: the property string name to look up. + + Returns: + True: this dict has that key; False otherwise. + """ + + return prop in self.properties + + def get_property( self, prop ): + """In this Master Structure's mappings, get the object that key maps to; these are Systemd Directives. + + Args: + key: the name of the Directive to return. + + Returns: + The object key maps to in the Element's Systemd Directives, or None if the key is + not in the metadata. + """ + + try: + return self.properties[ prop ] + except (IndexError, KeyError) as error: + self.log.warning("Property Key: {} not in the properties of this unit.".format( prop )) + return None + +class Alias( Element ): + """A symbolic link to a unit file. The term Alias is used in Systemd documentation. + """ + + TypeKey = 'ALIAS' + + @staticmethod + def vertex_attrs( node_label ): + """Return a dictionary containing the Alias Cytoscape vertex attributes + + Args: + node_label: + + Returns: + A dictonary containing the attributes for this vertex; the keys are special. + """ + + # sets subgraph, node_label, node_label_width, node_height, node_width + attrs = Element.get_default_vertex_attrs( Alias.TypeKey, node_label ) + + # set the specifics for node_label_color, node_fill_color, node_shape + # attrs['node_label_color'] = colors.basic_colors['white'] + attrs['node_fill_color'] = colors.element_fill_colors[Alias.TypeKey] + attrs['node_shape'] = 'ROUND_RECTANGLE' + + return attrs + + @staticmethod + def edge_attrs( edge_label=None ): + """Return a dictionary containing the Alias Cytoscape edge attributes + + Args: + edge_label: + + Returns: + A dictonary containing the attributes for this edge; the keys are special. + """ + + if not edge_label: + edge_label = Alias.TypeKey + + attrs = Element.get_default_edge_attrs( Alias.TypeKey, edge_label ) + + # set the specifics for edge_label_color, edge_line_type, edge_color + attrs['edge_line_type'] = 'EQUAL_DASH' + attrs['edge_color'] = colors.purple_colors['dark'] + + return attrs + + def __init__( self, uid, data, log ): + """Constructor for an Alias Element + + Args: + uid: the unique identifier string. + data: the dictionary from master structure that contains Alias information. + log: logger for messaging. + """ + + super().__init__( uid, data, log ) + self._key = ( self.id(), Alias.TypeKey ) + + self.source = self.id() + self.target = "{}{}".format( self.get_data( 'sym_link_target_path' ), self.get_data( 'sym_link_target_unit' ) ) + + def get_vertex_attrs( self ): + """Return a dictionary containing the Alias Cytoscape vertex attributes + + Returns: + A dictonary containing the attributes for this vertex; the keys are special. + """ + return Alias.vertex_attrs( self.id() ) + + def get_edge_attrs( self, edge_label ): + """Return a dictionary containing the edge Cytoscape edge attributes + + Args: + edge_label: + + Returns: + A dictonary containing the attributes for this edge; the keys are special. + """ + return Alias.edge_attrs( edge_label ) + + def get_children_keys( self ): + """For an Alias, the children and NOT Element INSTANCES, so we just return the list of pairs of strings. + """ + + if not self._children: + self.get_children() + + return self._children + + def get_children( self ): + if not self._children: + self._children.add( ( self.get_data('sym_link_target_unit'), 'UNIT' ) ) + return self._children + + def add_to_graph( self, G ): + """Add this Element instance to graph G + + Args: + G: the graph to add this Element to. + """ + G.add_node( repr(self), **Alias.vertex_attrs( self.id() ) ) + + def make_graph_edges( self, G ): + for c in self.get_children_keys(): + G.add_edge( repr(self), repr(c), **Alias.edge_attrs( Alias.TypeKey ) ) + +class Unit( Element ): + """A systemd unit file; Unit Elements are NOT uniquely identified by full path. + """ + + TypeKey = 'UNIT' + TemplateMatcher = re.compile( '^\S+@\S+\.\S+$' ) + + @staticmethod + def vertex_attrs( node_label ): + """Return a dictionary containing the Unit Cytoscape vertex attributes + + Args: + node_label: + + Returns: + A dictonary containing the attributes for this vertex; the keys are special. + """ + + # sets subgraph, node_label, node_label_width, node_height, node_width + attrs = Element.get_default_vertex_attrs( Unit.TypeKey, node_label ) + + # set the specifics for node_label_color, node_fill_color, node_shape + attrs['node_label_color'] = colors.basic_colors['white'] + attrs['node_fill_color'] = colors.element_fill_colors[Unit.TypeKey] + attrs['node_shape'] = 'RECTANGLE' + + return attrs + + @staticmethod + def edge_attrs( edge_label ): + """Return a dictionary containing the edge Cytoscape edge attributes + Args: + edge_label: + + Returns: + A dictonary containing the attributes for this edge; the keys are special. + """ + + # sets directed, subgraph, interaction are set here + attrs = Element.get_default_edge_attrs( Unit.TypeKey, edge_label ) + + attrs['edge_line_type'] = 'SOLID' + attrs['edge_color'] = colors.green_colors['dark'] + + return attrs + + + def __init__( self, path, data, remote_path, master_struct, log ): + """ + Args: + path: The unit file's path + data: The master structure data dictionary. + remote_path: the base path on this filesystem to the firmware image; + needed to file and process any executables within the firmware. + log: message logger. + """ + + super().__init__( path, data, log ) + + self.remote_path = remote_path + self.master_struct = master_struct + + # for files, the uid is just their unit name. + self._key = ( Path( path ).name, Unit.TypeKey ) + + def get_vertex_attrs( self ): + """Return a dictionary containing the Unit Cytoscape vertex attributes + + Returns: + A dictonary containing the attributes for this vertex; the keys are special. + """ + + return Unit.vertex_attrs( self.id() ) + + def get_edge_attrs( self, edge_label ): + """Return a dictionary containing the Unit Cytoscape edge attributes + + Args: + edge_label: + + Returns: + A dictonary containing the attributes for this edge; the keys are special. + """ + + return Unit.edge_attrs( edge_label ) + + def get_children( self ): + """Get the set of "direct" children for this Unit. These are Command Element instances. + + Returns: + the set of Unit children that are associated with Commands that are executed. + """ + + if not self._children: + # the child set is empty. + for exec_directive in Command.Directives: + if self.has_property( exec_directive ): + # this unit contains an "Exec" directive; these will be constructed as distinct nodes. + # each execfile can perform multiple commands although this is not usual. + exec_elt = Command( exec_directive, self.get_property( exec_directive ), self.remote_path, self.master_struct, self.log ) + self._children.add( exec_elt ) + + return self._children + + def get_property_children( self, prop ): + """Get the set of "property" children for this Unit. These are Unit Instances that are associated + with specific directives in a Unit file's definition. + + Args: + prop: The Systemd directive property string to use as a key. + + Returns: + The set of Unit Instances that are associates with dependency Systemd directives. + """ + + s = set() + if prop in Element.EdgeDirectives and self.has_property( prop ): + for c in self.get_property( prop ): + s.add( ( c, 'UNIT' ) ) + return s + + def get_sequencing_children( self, prop ): + """Get the set of "sequencing" children for this Unit. These are Unit Instances that are associated + with the After= and Before= directives that establish Unit ordering. + + Args: + prop: The Systemd directive property string to use as a key. + + Returns: + The set of Unit Instances that are associates with sequencing Systemd directives. + """ + + s = set() + if prop in ('After', 'Before') and self.has_property( prop ): + for c in self.get_property( prop ): + s.add( ( c, 'UNIT' ) ) + return s + + def add_to_graph( self, G ): + """Add this Element instance to graph G + + Args: + G: the graph to add this Element to. + """ + G.add_node( repr(self), **Unit.vertex_attrs( self.id() ) ) + + def make_graph_edges( self, G ): + """Creates all the edges (and possibly new nodes) based on information in this Unit Element. + + Args: + G: the networkx graph object for this Systemd specification. + """ + + for c in self.get_children(): + # Unit edge attributes for now with the label being the type of command. + G.add_edge( repr(self), repr(c), **self.get_edge_attrs( c.exec_directive ) ) + + for p in Element.EdgeDirectives: + for c in self.get_property_children( p ): + + # There are some cases where nodes are created here but NOT normally. + # we need to establish their attributes. + if repr(c) not in G: + label = 'UNKNOWN' + if Unit.TemplateMatcher.match( c[0] ): + label = 'TEMPLATE' + G.add_node( repr(c), **Element.get_default_vertex_attrs( label, c[0] ) ) + + G.add_edge( repr(self), repr(c), **self.get_edge_attrs( p ) ) + + for seq in ( 'After', 'Before' ): + for c in self.get_sequencing_children( seq ): + if repr(c) not in G: + label = 'UNKNOWN' + G.add_node( repr(c), **Element.get_default_vertex_attrs( label, c[0] ) ) + + if seq == 'After': + # this unit comes AFTER the one in the list (edge direction opposite) + G.add_edge( repr(c), repr(self), **self.get_edge_attrs( seq ) ) + else: + G.add_edge( repr(self), repr(c), **self.get_edge_attrs( seq ) ) + +class DropInFile( Element ): + """DropIn files are 'unit_file' types that are named using their full path because the name could be duplicated. + + These files contain Systemd Directives that may include Exec* directives that should be parsed as well. + """ + + TypeKey = 'DROPIN' + TemplateMatcher = re.compile( '^\S+@\S+\.\S+$' ) + + @staticmethod + def vertex_attrs( node_label ): + """Return a dictionary containing the DropInFile Cytoscape vertex attributes + + Args: + node_label: + + Returns: + A dictonary containing the attributes for this vertex; the keys are special. + """ + + # sets subgraph, node_label, node_label_width, node_height, node_width + attrs = Element.get_default_vertex_attrs( DropInFile.TypeKey, node_label ) + + # set the specifics for node_label_color, node_fill_color, node_shape + attrs['node_label_color'] = colors.basic_colors['white'] + attrs['node_fill_color'] = colors.element_fill_colors[DropInFile.TypeKey] + attrs['node_shape'] = 'RECTANGLE' + + return attrs + + @staticmethod + def edge_attrs( edge_label ): + """Return a dictionary containing the DropInFile Cytoscape edge attributes + + Args: + edge_label: + + Returns: + A dictonary containing the attributes for this edge; the keys are special. + """ + + # sets directed, subgraph, interaction are set here + attrs = Element.get_default_edge_attrs( DropInFile.TypeKey, edge_label ) + + # set the specifics for edge_label_color, edge_line_type, edge_color + attrs['edge_line_type'] = 'EQUAL_DASH' + attrs['edge_color'] = colors.green_colors['light'] + return attrs + + def __init__( self, uid, data, remote_path, master_struct, log ): + """Constructor for a DropInFile Instance + + Args: + uid: + data: + remote_path: + log: + """ + + super().__init__( uid, data, log ) + self._key = ( uid, DropInFile.TypeKey ) + self.remote_path = remote_path + self.master_struct = master_struct + + # this could be a template instantiation and not exist in our vertex set. + self.target = Path( self.id() ).parent.stem + + def get_vertex_attrs( self ): + """Return a dictionary containing the DropInFile Cytoscape vertex attributes + + Returns: + A dictonary containing the attributes for this vertex; the keys are special. + """ + + return DropInFile.vertex_attrs( self.id() ) + + def get_edge_attrs( self, edge_label ): + """Return a dictionary containing the DropInFile Cytoscape edge attributes + + Args: + edge_label: + + Returns: + A dictonary containing the attributes for this edge; the keys are special. + """ + + return DropInFile.edge_attrs( edge_label ) + + def get_children( self ): + """Return this DropInFile's children as a set of DropInFile instances. + + Returns: + The set of children of this Element as Command instances. + """ + + if not self._children: + # the child set is empty. + for exec_directive in Command.Directives: + if self.has_property( exec_directive ): + + # this unit contains an "Exec" directive; these will be constructed as distinct nodes. + # each execfile can perform multiple commands although this is not usual. + exec_elt = Command( exec_directive, self.get_property( exec_directive ), self.remote_path, self.master_struct, self.log ) + self._children.add( exec_elt ) + return self._children + + def get_property_children( self, prop ): + """Get the set of "property" children for this DropInFile. These are Unit Instances that are associated + with specific directives in a Unit file's definition. + + Args: + prop: The Systemd directive property string to use as a key. + + Returns: + The set of Unit Instances that are associates with dependency Systemd directives. + """ + + s = set() + if prop in Element.EdgeDirectives and self.has_property( prop ): + for c in self.get_property( prop ): + s.add( ( c, 'UNIT' ) ) + return s + + def get_sequencing_children( self, prop ): + """Get the set of "sequencing" children for this DropInFile. These are Unit Instances that are associated + with the After= and Before= directives that establish Unit ordering. + + Args: + prop: The Systemd directive property string to use as a key. + + Returns: + The set of Unit Instances that are associates with sequencing Systemd directives. + """ + + s = set() + if prop in ('After', 'Before') and self.has_property( prop ): + for c in self.get_property( prop ): + s.add( ( c, 'UNIT' ) ) + return s + + def add_to_graph( self, G ): + """Add this Element instance to graph G + + Args: + G: the graph to add this Element to. + """ + + G.add_node( repr(self), **DropInFile.vertex_attrs( self.id() ) ) + + def make_graph_edges( self, G ): + """Creates all the edges (and possibly new nodes) based on information in this Unit Element. + + Args: + G: the networkx graph object for this Systemd specification. + """ + + for c in self.get_children(): + # Unit edge attributes for now with the label being the type of command. + G.add_edge( repr(self), repr(c), **self.get_edge_attrs( c.exec_directive ) ) + + for p in Element.EdgeDirectives: + for c in self.get_property_children( p ): + # There are some cases where nodes are created here but NOT normally. + # we need to establish their attributes. + if repr(c) not in G: + label = 'UNKNOWN' + if DropInFile.TemplateMatcher.match( c[0] ): + label = 'TEMPLATE' + G.add_node( repr(c), **Element.get_default_vertex_attrs( label, c[0] ) ) + + G.add_edge( repr(self), repr(c), **self.get_edge_attrs( p ) ) + + for seq in ( 'After', 'Before' ): + for c in self.get_sequencing_children( seq ): + if repr(c) not in G: + label = 'UNKNOWN' + G.add_node( repr(c), **Element.get_default_vertex_attrs( label, c[0] ) ) + + if seq == 'After': + # this unit comes AFTER the one in the list (edge direction opposite) + G.add_edge( repr(c), repr(self), **self.get_edge_attrs( seq ) ) + else: + G.add_edge( repr(self), repr(c), **self.get_edge_attrs( seq ) ) + + def get_target( self ): + return self.target + +class CommandLine: + """Represents a command found in an Exec* directive in a Systemd Unit file. This class will parse + these specially formatted lines and provide accessors to all the elements within the command. + + This is NOT AN ELEMENT. + """ + + SpecialPrefixes = ('@', '-', ':', '+', '!' ) + + def __init__( self, cstr ): + """Constructor for a CommandLine instances""" + + self.cstr = cstr + self.prefixes = set() + self.executable = None + self.arguments = None + self.__parse_command_string( cstr ) + + def get_executable( self ): + """Return the executable command string; this usually includes the full path. + + Returns: + A string that is the path to the executable. + """ + + return self.executable + + def __str__( self ): + """The command line WITHOUT the Systemd prefix characters. + + Returns: + The command line without prefixes. + """ + + s = self.executable + if self.arguments: + # add arguments only if they are present. + s += ' ' + self.arguments + return s + + def __parse_command_string( self, cstr ): + """Make a Command instance from the cstr. This parses out the special prefix characters and + splits the executable command from its arguments. + + Args: + cstr: a command string found in a systemd unit file; these have special prefixes. + + Returns: + An ExecCommand instance that characterizes the command and its prefixes. + """ + + parts = cstr.split(maxsplit=1) + + print("cstr: {} parts: {}".format( cstr, parts )) + + # the command may NOT have any arguments. + if len(parts)>1: + self.arguments = parts[1] + + executable = parts[0] + + i = 0 + while i < len(executable): + if executable[i] in CommandLine.SpecialPrefixes: + ch = executable[i] + if ch == '!': + try: + if executable[i+1] == '!': + i += 1 + ch += '!' + except IndexError: + # end of the executable; this is a problem, but just record the single ! + pass + self.prefixes.add( ch ) + else: + # we have reached the actual path to the executable. + break + i += 1 + # set the executable WITHOUT the special prefix characters. + self.executable = executable[i:] + +class Command( Element ): + """An Element that represents a SEQUENCE of executable commands (usually only one tho) that are associated with + ONLY ONE of the following directives: + + ExecStart : command to execute when this service is started. + ExecCondition : optional commands executed before ExecStartPre + ExecStartPre : commands executed BEFORE ExecStart + ExecStartPost : commands executed AFTER ExecStart + ExecReload : commands to execute to trigger a configuration reload for the service. + ExecStop : commands that will stop a service + ExecStopPost : commands to execute AFTER the service is stopped. + + A Unit Element instance is the parent of a Command Element instance. + So, a single UNIT file (e.g., service) could generate several of Command instances because several Exec* directives + could be used. + + First line must be an ABSOLUTE path to an executable or a simplified filename without any slashes. + There may also be a prefix character used: + @ : second token passed as argv[0] + - : failure exit code has no effect. + : : no environment variable substitution + + : executed with FULL PRIVILEDGES + ! : execute with ELEVATED PRIVILEDGES + !! : also related to PRIVILEDGES + """ + + TypeKey = 'COMMAND' + + Directives = ('ExecStart', 'ExecCondition', 'ExecStartPre', 'ExecStartPost', 'ExecReload', 'ExecStop', 'ExecStopPost') + + @staticmethod + def vertex_attrs( node_label ): + """Return a dictionary containing the Command Cytoscape vertex attributes + + Args: + node_label: + + Returns: + A dictonary containing the attributes for this vertex; the keys are special. + """ + + # sets subgraph, node_label, node_label_width, node_height, node_width + attrs = Element.get_default_vertex_attrs( Command.TypeKey, node_label ) + + # set the specifics for node_label_color, node_fill_color, node_shape + # attrs['node_label_color'] = colors.orange_colors['darkest'] + attrs['node_fill_color'] = colors.element_fill_colors[Command.TypeKey] + attrs['node_shape'] = 'RECTANGLE' + return attrs + + @staticmethod + def edge_attrs( edge_label ): + """Return a dictionary containing the Edge Cytoscape edge attributes + + Args: + edge_label: + + Returns: + A dictonary containing the attributes for this edge; the keys are special. + """ + + # sets directed, subgraph, interaction are set here + attrs = Element.get_default_edge_attrs( Command.TypeKey, edge_label ) + + # set the specifics for edge_label_color, edge_line_type, edge_color + attrs['edge_line_type'] = 'SOLID' + attrs['edge_color'] = colors.orange_colors['dark'] + return attrs + + def __init__( self, exec_directive, exec_list, remote_path, master_struct, log ): + """Constructor for an Command Element + + A single command could be called by multiple unit files. All units that use these commands + should be captured as parents. + + Args: + exec_directive: The Systemd executable directive, e.g., ExecStart + exec_list: A list of command strings to execute; the master struct breaks these out into a list. + remote_path: The prefix path on this system needed to get to the firmware root. + log: logging messaging. + + Returns: + A Command instance. + + Raises: + Exception when the directive is not correct. + + """ + + # we will update the id later. + super().__init__( None, dict(), log ) + + if exec_directive not in Command.Directives: + raise Exception("Bad Exec command directive: {}".format( exec_directive )) + + self.exec_directive = exec_directive + self.master_struct = master_struct + + self.commands = list() + full_command = self.__parse_commands( exec_list ) + + self._key = ( full_command, "{}.{}".format( Command.TypeKey, exec_directive[4:].upper() ) ) + self.remote_path = remote_path + + def get_vertex_attrs( self ): + """Return a dictionary containing the Command Cytoscape vertex attributes + + Returns: + A dictonary containing the attributes for this vertex; the keys are special. + """ + + return Command.vertex_attrs( self.id() ) + + def get_edge_attrs( self, edge_label ): + """Return a dictionary containing the Command Cytoscape edge attributes + + Args: + edge_label: + + Returns: + A dictonary containing the attributes for this edge; the keys are special. + """ + + return Command.edge_attrs( edge_label ) + + def get_children( self ): + """Return this Command's children as a set of Executable instances. + + Returns: + The set of children of this Command Element as Executable instances. + """ + + if not self._children: + # the child set is empty. + for cmd in self.commands: + self._children.add( Executable( cmd.get_executable(), self.remote_path, self.master_struct, self.log ) ) + return self._children + + def add_to_graph( self, G ): + """Add this Element instance to graph G + + Args: + G: the graph to add this Element to. + """ + + label = '\n'.join( self.id().split(';') ) + G.add_node( repr(self), **Command.vertex_attrs( label ) ) + + def make_graph_edges( self, G ): + """Creates all the edges based on information in this Unit Element. + + Args: + G: the networkx graph object for this Systemd specification. + """ + + for c in self.get_children(): + # Command edge attributes for now with the label being the type of command. + G.add_edge( repr(self), repr(c), **self.get_edge_attrs( Executable.TypeKey ) ) + + def __parse_commands( self, exec_list ): + """Parse the list of executable commands that the exec_directive systemd directive maps to. + + There could be multiple commands in this single directive, so add each one to a commands + list. The sequence in the list is the execution order of the individual commands. + + Args: + exec_list: a list of executable commands; The master structure breaks the commands down into + a list of individual commands. + + Returns: + A unique string identifier for this list of commands; it is the concatenation of all of them. + TODO: If this properties list is empty, then we will get an error...! + + Raises: + Exception when the exec_directive key is NOT in the self.properties map. + """ + + for cstr in exec_list: + # go through each command in the list. + if not len(cstr): + # JMC: There was a case of an empty command string; for now just skip it. + self.log.warning("Empty command string found in directive: {} key: {}".format( self.exec_directive, self.key )) + else: + self.commands.append( CommandLine( cstr ) ) + + full_command = '; '.join( [ str(c) for c in self.commands ] ) + return full_command + +class Executable( Element ): + """A single binary that is executed as part of a Command (e.g., starting a service) + + The parents of Executables are Commands; each Command may have multiple Executable children. + + Each Executable (distinct binary) has the following children: + - Library set + - String set + + This DOES NOT include the ARGUMENTS to the command. + + We will use this class to also execute linux tools to perform forensics on this particular binary. + These tools should be as general as possible, so they can accomodate a variety of architectures. + """ + + TypeKey = 'EXECUTABLE' + + @staticmethod + def vertex_attrs( node_label ): + """Get the attribute dictionary to use in cytoscape for this Alias vertex. + + Args: + Returns: + the attribute dictionary. + """ + + # sets subgraph, node_label, node_label_width, node_height, node_width + attrs = Element.get_default_vertex_attrs( Executable.TypeKey, node_label ) + + # set the specifics for node_label_color, node_fill_color, node_shape + # attrs['node_label_color'] = colors.purple_colors['white'] + attrs['node_fill_color'] = colors.element_fill_colors[Executable.TypeKey] + attrs['node_shape'] = 'ROUND_RECTANGLE' + + return attrs + + @staticmethod + def edge_attrs( edge_label ): + """Get the attribute dictionary to use in cytoscape for this Alias edge. + Args: + Returns: + the attribute dictionary. + """ + + # sets directed, subgraph, interaction are set here + attrs = Element.get_default_edge_attrs( Executable.TypeKey, edge_label ) + + # set the specifics for edge_label_color, edge_line_type, edge_color + attrs['edge_line_type'] = 'SOLID' + attrs['edge_color'] = colors.purple_colors['dark'] + + return attrs + + + def __init__( self, executable, remote_path, master_struct, log ): + """Constructor for an Executable instance. + + Args: + executable: + remote_path: + log: + + Returns: + An Executable Instance. + """ + + super().__init__( executable, dict(), log ) + + self.log = log + self._key = ( executable, Executable.TypeKey ) + self.remote_path = remote_path + self.binary_path = "{}{}".format( remote_path, executable ) + self.executable = executable + self.master_struct = master_struct + + self.dlibs = set() + self.fstrings = set() + self.pstrings = set() + + def get_vertex_attrs( self ): + """Return a dictionary containing the Executable Cytoscape vertex attributes + + Returns: + A dictonary containing the attributes for this vertex; the keys are special. + """ + + return Executable.vertex_attrs( self.id() ) + + def get_edge_attrs( self, edge_label ): + """Return a dictionary containing the Executable Cytoscape edge attributes + + Args: + edge_label: + + Returns: + A dictonary containing the attributes for this edge; the keys are special. + """ + + return Executable.edge_attrs( edge_label ) + + def get_children( self ): + """Return this Command's children as a set of Executable instances. + + TODO: This is WHERE WE WOULD AUGMENT WHAT WE RUN AGAINST AN EXECUTABLE TO EXTRACT + FORENSIC INFORMATION. + + Returns: + The set of children of this Command Element as Executable instances. + """ + + if not self._children: + # the child set is empty. + self._children.update( self.get_dynamic_libs() ) + self._children.update( self.get_file_strings() ) + self._children.update( self.get_path_strings() ) + return self._children + + def add_to_graph( self, G ): + """Add this Element instance to graph G + + Args: + G: the graph to add this Element to. + """ + G.add_node( repr(self), **Executable.vertex_attrs( self.id() ) ) + + def make_graph_edges( self, G ): + """Creates all the edges based on information in this Command Element. + + Args: + G: the networkx graph object for this Systemd specification. + """ + + for c in self.get_children(): + s_str = repr(self) + t_str = repr(c) + if ( s_str, t_str ) not in G.edges(): + # When a single Executable is called multiple times, e.g., used with different command line arguments -- + # lets say we are starting a webserver and stopping a webserver -- same executable multiple commands and options. + # there will be multiple edges created from that executable to its supporting libraries and strings. + # this is CORRECT based on how this code was intended (proper multigraph), but it may clutter the graph. + # This mechanism eliminates THIS PARTICULAR set of multigraph edges. + + # Executable edge attributes for now with the label being the type of command. + G.add_edge( s_str, t_str, **self.get_edge_attrs( c.get_type() ) ) + + def get_dynamic_libs( self ): + """Run a linux command to extract the supporting dynamic libraries (if any) for this executable. + + Execute the external command in a subprocess, gather the input, and perform the regex filter + internally for the data of interest. + + Returns: + The set of Library instances; these have the methods to grab attributes for graphing. + """ + + if not self.dlibs: + # only do this operation one time. + # do not need a / character here because the remote path does NOT end in one + # and the executable is a full system path that begins with a / + + dlib_names = self.master_struct['libraries'][self.executable] + self.dlibs = { Library( name, self.log ) for name in dlib_names } + + return self.dlibs + + def get_file_strings( self ): + """Use the binutils strings command on the executable to find files that may be of use. + + Args: + extension_list: a list of file extensions to search for within the strings. + + Returns: + a set of strings (maybe file paths) that could indicate a file that is used by this + binary. + """ + + if not self.fstrings: + # only do this operation one time. + + file_strings = self.master_struct['files'][self.executable] + + for s in file_strings: + str_elt = String( s.strip(), 'FILE', self.log ) + self.fstrings.add( str_elt ) + + return self.fstrings + + def get_path_strings( self ): + """Use the binutils strings command on the executable to find paths that may be of use. + + Returns: + a set of strings (maybe paths) that could indicate a path/file that is used by this + binary. + """ + + if not self.pstrings: + # only do this operation one time. + # do not need a / character here because the remote path does NOT end in one + # and the executable is a full system path that begins with a / + + path_strings = self.master_struct['strings'][self.executable] + + for s in path_strings: + str_elt = String( s.strip(), 'PATH', self.log ) + self.pstrings.add( str_elt ) + + return self.pstrings + +class Library( Element ): + """A dynamic library that is needed by an Executable. + + This is MEANT to have NO CHILDREN. + """ + + TypeKey = 'LIBRARY' + + @staticmethod + def vertex_attrs( node_label ): + """Get the attribute dictionary to use in cytoscape for this Alias vertex. + + Args: + Returns: + the attribute dictionary. + """ + + # sets subgraph, node_label, node_label_width, node_height, node_width + attrs = Element.get_default_vertex_attrs( Library.TypeKey, node_label ) + + # set the specifics for node_label_color, node_fill_color, node_shape + # attrs['node_label_color'] = colors.blue_colors['dark'] + attrs['node_fill_color'] = colors.element_fill_colors[Library.TypeKey] + attrs['node_shape'] = 'ROUND_RECTANGLE' + + return attrs + + def edge_attrs( edge_label ): + """Get the attribute dictionary to use in cytoscape for this Alias edge. + Args: + Returns: + the attribute dictionary. + """ + + # sets directed, subgraph, interaction are set here + attrs = Element.get_default_edge_attrs( Library.TypeKey, edge_label ) + + # set the specifics for edge_label_color, edge_line_type, edge_color + attrs['edge_line_type'] = 'SOLID' + attrs['edge_color'] = colors.dark_colors['blue'] + + return attrs + + def __init__( self, libname, log ): + """Construct a Library instance + + Returns: + A Library instance. + """ + + super().__init__( libname, dict(), log ) + self._key = ( libname, Library.TypeKey ) + + def get_vertex_attrs( self ): + """Return a dictionary containing the Library Cytoscape vertex attributes + + Returns: + A dictonary containing the attributes for this vertex; the keys are special. + """ + return Library.vertex_attrs( self.id() ) + + def get_edge_attrs( self, edge_label ): + """Return a dictionary containing the Library Cytoscape edge attributes + + Args: + edge_label: + + Returns: + A dictonary containing the attributes for this edge; the keys are special. + """ + + return Library.edge_attrs( edge_label ) + + def add_to_graph( self, G ): + """Add this Element instance to graph G + + Args: + G: the graph to add this Element to. + """ + G.add_node( repr(self), **Library.vertex_attrs( self.id() ) ) + + def make_graph_edges( self, G ): + """These are leafs, no children""" + pass + +class String( Element ): + """A specific string extracted from an Executable + + This is MEANT to have NO CHILDREN. + """ + + TypeKey = 'STRING' + + @staticmethod + def vertex_attrs( node_label ): + """Get the attribute dictionary to use in cytoscape for this Alias vertex. + + Args: + Returns: + the attribute dictionary. + """ + + # sets subgraph, node_label, node_label_width, node_height, node_width + attrs = Element.get_default_vertex_attrs( String.TypeKey, node_label ) + + # set the specifics for node_label_color, node_fill_color, node_shape + # attrs['node_label_color'] = colors.dark_colors['purple'] + attrs['node_fill_color'] = colors.element_fill_colors[String.TypeKey] + attrs['node_shape'] = 'ROUND_RECTANGLE' + + return attrs + + @staticmethod + def edge_attrs( edge_label ): + """Get the attribute dictionary to use in cytoscape for this Alias edge. + Args: + Returns: + the attribute dictionary. + """ + + # sets directed, subgraph, interaction are set here + attrs = Element.get_default_edge_attrs( String.TypeKey, edge_label ) + + # set the specifics for edge_label_color, edge_line_type, edge_color + attrs['edge_line_type'] = 'SOLID' + attrs['edge_color'] = colors.purple_colors['dark'] + + return attrs + + def __init__( self, string, category, log ): + """Construct a String instance. + + Returns: + A String instance. + """ + + super().__init__( string, dict(), log ) + self._key = ( string, "{}.{}".format(String.TypeKey,category) ) + + def get_vertex_attrs( self ): + """Return a dictionary containing the String Cytoscape vertex attributes + + Returns: + A dictonary containing the attributes for this vertex; the keys are special. + """ + + return String.vertex_attrs( self.id() ) + + def get_edge_attrs( self, edge_label ): + """Return a dictionary containing the String Cytoscape edge attributes + + Args: + edge_label: + + Returns: + A dictonary containing the attributes for this edge; the keys are special. + """ + + return String.edge_attrs( edge_label ) + + def add_to_graph( self, G ): + """Add this Element instance to graph G + + Args: + G: the graph to add this Element to. + """ + G.add_node( repr(self), **String.vertex_attrs( self.id() ) ) + + def make_graph_edges( self, G ): + """These are leafs, no children""" + pass diff --git a/lib/file_handlers.py b/lib/file_handlers.py new file mode 100644 index 0000000..74aad81 --- /dev/null +++ b/lib/file_handlers.py @@ -0,0 +1,121 @@ +''' +file_handlers.py +Authors: Mike Huettel, Jason M. Carter +Date: December 2023 +Version: 1.0 + +Licensed under the Apache License, Version 2.0 (the "License") +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +Contributors: + Oak Ridge National Laboratory + +Description: This file is currently unused, but is intended to provide a central location for all + file handling/processing operations when more functionality is added to the tool. In the future, + I would like to include the ability to specify input files, possibly encode or format the data in + other formats, etc., but for now it's just able to deal with the output files. For more information, + see function comments or the README.md. +''' + +import logging + +from json import JSONEncoder, dump, load +from json.decoder import JSONDecodeError +from pathlib import Path +from sys import exit + + +class MyEncoder(JSONEncoder): + """Allows us to send arbitrary data structures to json.dump and json.dumps and get reasonable + output. This will detect SETS and convert then to LISTS that json knows how to output. + NOTE: Here we are IGNORING several of the critical objects. If custom encoders are needed place + them here.""" + + def default(self, obj): + if isinstance(obj, set): + return list(obj) + if isinstance(obj, tuple): + return list(obj) + return JSONEncoder.default(self, obj) + + +def load_input_file(user_path: str, log: logging) -> dict: + '''Takes a user path as a string, checks to verify it is a file, and then returns the dictionary saved in the file.''' + + try: + with open(user_path) as user_file: + master_struct = load(user_file) + log.info( f'Successfully de-serialized file: {user_path}' ) + log.vdebug( f'Data extracted:\n\n{master_struct}' ) + return master_struct + + except FileNotFoundError as e: + log.error( f'{e}\n' ) + log.error( f'{user_path} is not a valid file, can\'t parse master struct from it. See error message above for more info.' ) + exit(1) + + except JSONDecodeError as e: + log.error( f'{e}\n' ) + log.error( f'Could not parse {user_path} properly. Fix file formatting or create a new systemd snapshot.' ) + exit(1) + + except IsADirectoryError as e: + log.error( f'{e}\n' ) + log.error( f'Given path "{user_path}" ends with a directory and not a file. Be sure to point to a master struct file if using deps or graph actions.' ) + exit(1) + + +def create_output_file(file_struct: dict, struct_type: str, output_file: str, overwrite: bool, log: logging) -> None: + '''The print_output() function will either send the final master_struct to a file or stdout depending on + whether the user includes the -o option when running systemd_mapper. If nothing is stored in the dict + that is being printed then a message will be sent to the user indicating no data was found. If there is + something in the specified dict, the function will either print the data to stdout or check to see if + the filename specified is already a file. If the file already exists then no file will be written to + avoid accidently overwriting files.''' + + if file_struct != {}: + + stype_len = len(struct_type) + 1 + + # strip possible suffixes from user input to ensure output_file comparison is accurate + if '.json' in output_file[-5:]: + output_file = output_file[:-5] + if f'_{struct_type}' in output_file[ -stype_len : ]: + output_file = output_file[ : -stype_len ] + + log.info( f'Writing data to {output_file}_{struct_type}.json...' ) + + if Path( f'{output_file}_{struct_type}.json' ).is_file() and overwrite == False: + log.warning( f'{output_file}_{struct_type}.json already exists. Skipping to avoid overwriting' ) + log.info('FAIL') + return + + try: + dump( file_struct, open( f'{output_file}_{struct_type}.json', 'w' ), indent=4, cls=MyEncoder ) + log.info('SUCCESS') + + except FileNotFoundError as e: + log.warning( f'{e}\n' ) + log.warning('Specified directory was not found. Verify the path you are trying to write to.') + log.info('FAIL') + return + + except PermissionError as e: + log.warning( f'{e}\n' ) + log.warning('Not allowed to access specified directory. Verify the path you are trying to write to.') + log.info('FAIL') + return + + else: + log.warning( f'Nothing in {struct_type} to print.' ) + log.info('FAIL') + return diff --git a/lib/grapher.py b/lib/grapher.py new file mode 100644 index 0000000..9b9cb6c --- /dev/null +++ b/lib/grapher.py @@ -0,0 +1,302 @@ +''' +grapher.py +Authors: Jason M. Carter, Mike Huettel +Date: December 2023 +Version: 1.0 + +Licensed under the Apache License, Version 2.0 (the "License") +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +Contributors: + Oak Ridge National Laboratory + +Description: This is the main logic for the tool that creates the cytoscape graph. After the master + struct is built, systemd snapshot can run the graphing functions. The graphing starts with the origin + unit file ('default.target' by default) and searches through the master struct to find any and all + relationships that are created by that unit. It does this relationship mapping for each unit file + in the master struct. For more information see doc strings. +''' + +from collections import defaultdict + +from lib.element import ElementFactory, Element, Unit, Alias, Command, Executable, Library, String +from lib.unit_file_lists import ms_only_keys + + + +class Grapher: + """Instances represent a single SystemD configuration inferred from static analysis of a filesystem + + Strategy: + - Build a networkx graph with attributes as we parse the files generated by the static analysis tool. + - Convert that graph into pandas dataframes. + - Transfer the graph to Cytoscape using the py4cytoscape create from dataframe functions. + + There are more direct ways than the outline above; however, this provides us with a well-formed networkx + graph, a pandas dataframe, and the ability to visualize it in Cytoscape. + """ + + # source and target are explicit + EdgeFields = { 'interaction', 'subgraph', 'directed', 'edge_label_color', 'edge_line_type', 'source_arrow_shape', 'target_arrow_shape', 'edge_color' } + + # id is explicit + VertexFields = { 'subgraph', 'node_label', 'node_label_color', 'node_label_width', 'node_fill_color', 'node_shape', 'node_height', 'node_width' } + + def __init__( self, system_name, log ): + self.log = log + self.system_name = system_name + self.emap = dict() + self.dset = set() + self.G = None + + self.init_grapher() + + + def init_grapher(self): + """Import modules needed for graphing. Importing these conditionally allows us to run systemd snapshot and + build a master struct on machines that don't have cytoscape or networkx installed without requiring each + of those machines to install additional software.""" + + global nx + global p4c + global pd + + import networkx as nx + import py4cytoscape as p4c + import pandas as pd # pandas dataframe for python cytoscape. + + return + + + @staticmethod + def make_edge_dataframe( G ): + # pandas dataframe uses a dictionary of lists. + edata = defaultdict(list) + # for each edge + for s, t, data in G.edges( data=True ): + edata['source'].append( s ) + edata['target'].append( t ) + for field in Grapher.EdgeFields: + if field in data: + edata[ field ].append( data[field] ) + else: + # must be here to meet the balanced dataframe requirement + edata[ field ].append( None ) + + return pd.DataFrame( data = edata, columns = edata.keys() ) + + @staticmethod + def make_vertex_dataframe( G ): + # pandas dataframe uses a dictionary of lists. + vdata = defaultdict(list) + # for each edge + for v, data in G.nodes( data=True ): + vdata['id'].append( v ) + for field in Grapher.VertexFields: + if field in data: + vdata[ field ].append( data[field] ) + else: + # must be here to meet the balanced dataframe requirement + vdata[ field ].append( None ) + + return pd.DataFrame( data = vdata, columns = vdata.keys() ) + + def get_network_name( self ): + """Uses only py4cytoscape""" + + # this still works and produces a list of all the network names in cytoscape. + self.log.debug("Getting network name...") + network_list = p4c.networks.get_network_list() + nname = self.system_name + i = 1 + # make names until you get something unique + while ( nname in network_list ): + nname = "{}.{}".format( self.system_name, i ) + i += 1 + self.system_name = nname + self.log.debug("Finished getting network name: {}".format(self.system_name)) + return self.system_name + + def create_cytoscape_graph( self, master_struct, force = False ): + """ + Args: + master_struct: + force: + + Returns: + The networkx graph that was built and sent to cytoscape for rendering. + """ + + self.build( master_struct, force ) + edf = Grapher.make_edge_dataframe( self.G ) + vdf = Grapher.make_vertex_dataframe( self.G ) + + gname = self.get_network_name() + p4c.networks.create_network_from_data_frames( vdf, edf, title=gname ) + return self.G + + def transmit_to_cytoscape( self, G ): + + self.log.debug("Transmitting graph data to cytoscape...") + edf = Grapher.make_edge_dataframe( G ) + vdf = Grapher.make_vertex_dataframe( G ) + gname = self.get_network_name() + p4c.networks.create_network_from_data_frames( nodes=vdf, edges=edf, title=gname ) + self.log.debug("Finished transmitting graph data to cytoscape...") + + def multigraph_dump( self, G ): + """Will dump the strange adjacency view of a MultiDiGraph from networkx + + TROUBLESHOOTING CODE. + + Args: + G : the Multigraph + """ + + + for uid, adj_udict in G.adj.items(): + print("vertex: {}".format( uid )) + for vk, vv in G.nodes[ uid ].items(): + self.log.vdebug("\tv att: {} : {}".format( vk, vv )) + + print("\tAdjacency Information") + + for adj_vid, edge_key_dict in adj_udict.items(): + + # vertices adjacent to uid + print("\tadj vertex: {}".format( adj_vid )) + + for uv_edge_id, edge_atts in edge_key_dict.items(): + + # u->v edge identifer because we could have mulitples + # and attributes + print("\t\tedge id: {} atts dict follows:".format( uv_edge_id )) + for att_key, att_value in edge_atts.items(): + print("\t\t\t{}: {}".format( att_key, att_value ) ) + + def build_tree( self, G, root, depth ): + """Build and return a tree from G rooted at root that has maximum depth, depth. + + This is handy when you only want to investigate a small portion of the overall Systemd structure. + Since it is hierarchical, the tree is a nice way to focus the lens to only those related items + from that particular root. + + Args: + G: A multigraph from which we want to extract a tree. + root: the vertex identifier (a string pair) in G that will be the starting point for the tree. + depth: how deep the tree will go (number of edges from root) + + Returns: + A networkx tree graph WITH THE ATTRIBUTE from the original graph. + + Raises: + Exception if the root node is NOT IN G. + We could just return the empty graph, but that seems much less informative. + """ + + self.log.debug("Starting subtree build...") + root_string = str(root) + + if root_string not in G: + raise Exception( "Root: {} for tree is not in the systemd graph".format( root_string )) + + # The tree created here has the "skeleton" of what we need. It is incomplete because we are working + # from a MultiDiGraph. In the graph we would like to derive from this tree there could be multiple + # edges where this tree only has one. Also NetworkX DOES NOT transfer all the attributes we + # have decorated our G with already; those need to be transfered. + + if depth > 0: + T = nx.dfs_tree( G, source=str( root_string ), depth_limit=depth ) + else: + # this will automagically search to the maximum depth it can. + T = nx.dfs_tree( G, source=str( root_string ) ) + + # T now has all the vertices and edges we want WITHOUT THE ATTRIBUTES and the possible extra edges. + # G has all the attributes but doesn't know which ( V, E ) are in T. + # Also, our output tree may have multiple edges between nodes, so we need another + # MultiDiGraph + + Gp = nx.MultiDiGraph() + for v in T: + # T is a subgraph of G, so v can be assumed to be in G. + Gp.add_node( v, **G.nodes[v] ) + + for s, t in T.edges(): + # T is a subgraph of G, so s, t can be assumed to be in G. + # We are transferring information from a MultiGraph, so we need to look at each possible + # edge AND each of those edges may have a unique set of attributes. This loop transfers + # those. + for multi_edge_id, edge_atts in G.adj[s][t].items(): + Gp.add_edge( s, t, **edge_atts ) + + self.log.debug("Finished subtree build...") + return Gp + + def build( self, master_struct : dict, rebuild_graph = False ): + """From a master file generated from the systemd_mapper tool (it is json), construct a new dictionary + that we can use to construct an annotated graph. + + Args: + master_struct: the data file with Systemd objects to use to build the graph. + rebuild_graph: when True any existing graph will be replaced after rebuilding it. + + Returns: + returned map: key -> xxx + + Raises: + """ + + self.log.debug("Building graph...") + + if not rebuild_graph and self.G: + # if it is built and we don't want to force re-build, just return the previous built graph. + return self.G + + self.G = nx.MultiDiGraph( name = 'systemd_graph' ) + + # get this here, in case it is NOT the first item in the dictionary; we need it for + # certain elements. CAUTION: this path does NOT CURRENTLY end in / + remote_path = master_struct['remote_path'] + + efactory = ElementFactory( remote_path, self.log ) + + for uid, data in master_struct.items(): + if uid in ms_only_keys: + # this is a special object in the mapping that cannot be converted into an Element instance; skip it. + continue + + if data['metadata']['file_type'] == 'dep_dir': + # the dependency directory objects are not used because the SPECIFIC edge information is in the other + # objects in the master structure. + continue + + # The remaining objects in the master structure may produce a collection of Element instances we want to + # add to the graph as vertices. + for e in efactory.make_element( uid, data, master_struct ): + self.log.debug( "made element: {}".format( e.key() )) + + if repr(e) in self.G: + # This is troubleshooting code. + self.log.debug("The vertex: {} is already in the graph.".format( e.key() )) + + # Objects may produce vertices that have ALREADY BEEN ADDED to the + # graph (see warnings above), that is fine: NetworkX will just update the attributes as needed. + e.add_to_graph( self.G ) + + # Each Element instance knows how to add edges to the graph; these are its children. + # Vertices may be added here as well and their attributes later updated above. + + e.make_graph_edges( self.G ) + + self.log.debug("Finished building graph...") + return self.G + diff --git a/lib/style.py b/lib/style.py new file mode 100644 index 0000000..36c6fef --- /dev/null +++ b/lib/style.py @@ -0,0 +1,237 @@ +''' +style.py +Authors: Jason M. Carter, Mike Huettel +Date: December 2023 +Version: 1.0 + +Licensed under the Apache License, Version 2.0 (the "License") +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +Contributors: + Oak Ridge National Laboratory + +Description: Instances represent a single Cytoscape graph style. These instances will be designed to better + visualize the data from the graphs generated by the static analysis tool. +''' + +import json + + + +class Style(): + """Instances represent a single Cytoscape graph style. These instances will be designed to better + visualize the data from the graphs generated by the static analysis tool. The key features we want + to differentiate are: + - Parent - Child relationships (edges are directed from parent to child) + - Types of dependencies could be visualized by different edge line styles, e.g., wants versus requires + - Vertices (units) could be visualized by different vertex shapes or colors. + - We also need to format the text within each node, etc. + + 1. Make a style instance whose name is the style name to use in Cytoscape. + - This style MAY ALREADY EXIST. + 2. We can force the system to be remade or customized. + 3. We can just activate the style we instantiated. + 4. If the style name doesn't exist we need to create it and send it to Cytoscape. + + + + """ + + DEFAULT_PROP_OBJ_KEYS = [ 'visualProperty', 'value' ] + MAPPING_PROP_OBJ_KEYS = [ 'mappingType', 'mappingColumn', 'mappingColumnType', 'visualProperty' ] + + @staticmethod + def get_style( style_name, log = None ): + """A little helper method to grab styles from cytoscape as a template for the one we'll + build as a base style file. + + Args: + style_name: the Cytoscape name for the style you would like to retrieve + + Returns: + A json file with the style information retrieved from Cytoscape. + """ + + style_json = {} + + if style_name not in Style.get_available_styles( log ): + if log: + log.warning("Cytoscape Style: %s is not available.", style_name ) + try: + style_json = p4c.styles.get_visual_style_JSON( style_name ) + + except: + if log: + log.warning("Cytoscape style: %s not found or problem with connection.", style_name ) + + return style_json + + @staticmethod + def write_style_file( style_name, filename, log=None ): + + style_json = Style.get_style( style_name ) + if style_json: + json.dump( style_json, open( filename, 'w' ), indent=4 ) + elif log: + log.warning("No style information was retieved for style: {}".format( style_name )) + + @staticmethod + def get_style_mappings_list( style_json ): + """Create a usable mappings list by stripping out the comments, etc in our base graph style. + + NOTE: This looks like we are just re-creating the dictionary, but we are actually ensuring + that the "documentation" fields are eliminated when sent to cytoscape; this way we can annotate + the style json with fields that would not be understood by cytoscape. + """ + + mappings = [] + if style_json and 'mappings' in style_json: + + # mappings maps to a list of objects. + for mapping_properties_dict in style_json['mappings']: + + # build a new dictionary with only the data we want. + new_mapping_properties_dict = { key : mapping_properties_dict[key] for key in Style.MAPPING_PROP_OBJ_KEYS } + + # points MAY NOT BE in all mapping styles; deal with that here. + if new_mapping_properties_dict['mappingType'] == 'continuous' and 'points' in mapping_properties_dict: + new_mapping_properties_dict['points'] = mapping_properties_dict['points'] + + mappings.append( new_mapping_properties_dict ) + + return mappings + + @staticmethod + def get_style_defaults_list( style_json ): + """Create a usable defaults list by stripping out the comments, etc in our base graph style. + """ + + defaults = [] + if style_json and 'defaults' in style_json: + for default_properties_dict in style_json['defaults']: + new_default_properties_dict = { key : default_properties_dict[key] for key in Style.DEFAULT_PROP_OBJ_KEYS } + defaults.append( new_default_properties_dict ) + return defaults + + @staticmethod + def make_new_style_name( proposed_style_name = "systemd_graph_style" ): + """Validate whether a proposed name will work, or generate a non-conflicting style name. + """ + + slist = Style.get_available_styles() + + i = 1 + new_style_name = proposed_style_name + while ( new_style_name in slist ): + new_style_name = "{}.{}".format( proposed_style_name, i ) + i += 1 + return new_style_name + + @staticmethod + def get_available_styles(log = None): + available_styles = [] + try: + available_styles = p4c.styles.get_visual_style_names() + except: + if log: + log.warning("There is a connection PROBLEM with Cytoscape; is it running.") + else: + print("There is a connection PROBLEM with Cytoscape; is it running?") + + return available_styles + + @staticmethod + def read_style_file( style_file, log = None ): + """Read a json style file from the specified file and return the json. + + Args: + style_file: a Path instance that points to a file. + + Returns: + a json object that contains the cytoscape style file. + """ + + style_json = {} + try: + with style_file.open() as json_file: + style_json = json.load( json_file ) + except: + if log: + log.error("Failed to read from the style file: %s", style_file ) + else: + print("Failed to read from the style file: %s", style_file ) + + return style_json + + def __init__( self, name, log): + """ + Args: + name: the Cytoscape style name for the style you would like to apply to the graph. + log : a logger. + """ + + self.log = log + self.name = name + + self.init_style() + + + def init_style(self): + """Import py4cytoscape to alow cytoscape styling. Importing these conditionally allows us to run systemd + snapshot and build a master struct on machines that don't have cytoscape installed without requiring each + of those machines to install additional software.""" + + global p4c + import py4cytoscape as p4c + + return + + + def is_present( self, style_name ): + """Set the available_styles instance variable after retrieving all the current styles in + cytoscape. + Then, return True if style_name is in that list and False otherwise. + """ + + + self.log.debug("Starting...") + self.available_styles = Style.get_available_styles( self.log ) + self.log.debug("Finishing...") + return style_name in self.available_styles + + def activate( self ): + """Make this style the active style for the current graph in cytoscape. + """ + + if self.is_present( self.name ): + self.log.info("Your selected style: {} is available to use in Cytoscape.".format( self.name )) + # JMC: These commands do not seem to work; if placed after set visual style they will prevent + # the style from being applied. + # p4c.match_arrow_color_to_edge( True ) + # p4c.lock_node_dimensions( False ) + p4c.styles.set_visual_style( self.name ); + else: + self.log.warning("Style named: %s is not an available style.", self.name ) + + def create( self, style_json ): + if self.is_present( self.name ): + self.name = Style.make_new_style_name() + + mappings = Style.get_style_mappings_list( style_json ) + defaults = Style.get_style_defaults_list( style_json ) + p4c.styles.create_visual_style( self.name, defaults=defaults, mappings=mappings ) + # JMC: These commands do not seem to work. + # p4c.match_arrow_color_to_edge( True ) + # p4c.lock_node_dimensions( False ) + p4c.styles.set_visual_style( self.name ) + return self.name + diff --git a/lib/sysd_obj_parser.py b/lib/sysd_obj_parser.py new file mode 100644 index 0000000..26c3778 --- /dev/null +++ b/lib/sysd_obj_parser.py @@ -0,0 +1,440 @@ +''' +sysd_obj_parser.py +Authors: Mike Huettel, Jason M. Carter +Date: December 2023 +Version: 1.0 + +Licensed under the Apache License, Version 2.0 (the "License") +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +Contributors: + Oak Ridge National Laboratory + +Description: This file is used to parse any of the systemd objects that are found in any +of the systemd unit file system paths. For more information, see the function comments +or the README.md. +''' + +import logging + +from pathlib import Path +from os import readlink, getcwd, chdir, path +from typing import Any, Dict, List + +from lib.unit_file_lists import possible_unit_opts, space_delim_opts + + + +class SystemdFileFactory: + + + def __init__(self, remote_path: str) -> None: + '''The SystemdFile class is the initial object created for each file to be parsed. After + creating this object shell, the systemd_mapping.py file will decide which type of file + it should be parsed as, and create a subclass modeled for that file type. This class + also contains the .record() and .info() handlers for each of the subclasses.''' + + self.remote_path = remote_path + + + def parse_file(self, unit_path: str, unit_name: str) -> Dict[str, Any]: + '''Check input to evaluate file type and parse accordingly''' + + self.unit_file_fp = Path( f'{self.remote_path}{unit_path}{unit_name}' ) + self.unit_path = unit_path + self.name = unit_name + + if self.unit_file_fp.is_dir(): + self.dep_dir = self.parse_dep_dir(self.unit_path) + return self.dep_dir.record() + elif self.unit_file_fp.is_symlink(): + self.sym_link = self.parse_sym_link(self.unit_path) + return self.sym_link.record() + elif self.unit_file_fp.is_file(): + self.unit_file = self.parse_unit_file(self.unit_path) + return self.unit_file.record() + else: + logging.warning( f'Error determining which systemd file type "{self.unit_file_fp}" is' ) + + + def parse_dep_dir(self, path: str) -> "DepDir": + '''SystemdFileFactory.parse_dep_dir() + + After the SystemdFile object is created, the systemd_mapping.py file checks to see what + type of subclass to create, and then sends it to the proper parse_file() function. + parse_dep_dir() creates a new DepDir subclass where it is then verified as a valid + dependency or config file directory, and then parsed accordingly.''' + + self.dep_dir = DepDir() + self.dep_dir.check_dep_dir(self.remote_path, path, self.name) + return self.dep_dir + + + def parse_sym_link(self, path: str) -> "SymLink": + '''SystemdFileFactory.parse_sym_link() + + After the SystemdFile object is created, the systemd_mapping.py file checks to see what + type of subclass to create, and then sends it to the proper parse_file() function. + parse_sym_link() creates a new SymLink subclass where it is then verified as a valid + symbolic link, and then parsed accordingly.''' + + self.sym_link = SymLink(self.remote_path, path, self.name) + return self.sym_link + + + def parse_unit_file(self, path: str) -> "UnitFile": + '''SystemdFileFactory.parse_unit_file() + + After the object is created, the systemd_mapping.py file checks to see what + type of subclass to create, and then sends it to the proper parse_file() function. + parse_unit_file() creates a new UnitFile subclass where it is verified as a valid + unit file, and then parsed accordingly.''' + + self.unit_file = UnitFile(path, self.name) + self.unit_file.update_unit_file(self.remote_path, path, self.name) + self.unit_file.check_implicit_dependencies(self.unit_file.unit_type) + return self.unit_file + + + +class DepDir: + + + def __init__(self) -> None: + '''The SystemdFileFactory.DepDir class will be created and will automatically call check_dep_dir() + to validate that it is a valid dependency or config directory any time systemd_mapping.py + discovers what it believes to be a dep dir. If it is a valid dep dir, the directory will + be parsed to record all of the dependencies within that folder along with which type of + dependencies are being created.''' + + self.dep_dir_paths: List[str] = [] + self.config_files: List[str] = [] + self.wants_deps: List[str] = [] + self.requires_deps: List[str] = [] + self.all_deps: List[str] = [] + + + def check_dep_dir(self, remote_path: str, path: str, dep_dir: str) -> None: + '''SystemdFileFactory.DepDir.check_dep_dir() + + Validates that a dep dir has been encountered. Can be one of .d, .wants, or .requires + directories. After validation, update functions are called to record the dependencies.''' + + self.dep_type: str = dep_dir.split('.')[-1] + + if self.dep_type == 'd': + self.update_dep_dir(remote_path, path, dep_dir) + self.update_config_files(self.dir_items) + elif self.dep_type == 'wants': + self.update_dep_dir(remote_path, path, dep_dir) + self.update_wants_deps(self.dir_items) + elif self.dep_type == 'requires': + self.update_dep_dir(remote_path, path, dep_dir) + self.update_requires_deps(self.dir_items) + else: + logging.warning( f'Unknown or invalid folder type: "{self.dep_type}" for {remote_path}{path}{dep_dir}' ) + + + def update_dep_dir(self, remote_path: str, path: str, dep_dir: str) -> None: + '''SystemdFileFactory.DepDir.update_dep_dir() + + Adds the dir path to the dep_dir_paths list and gets all file contents from the dep dir.''' + + self.dep_dir_paths.append( f'{path}{dep_dir}' ) + self.dir_items: List[str] = [ str(dep).split('/')[-1] for dep in Path( f'{remote_path}{path}{dep_dir}' ).glob('*') ] + + + def update_config_files(self, dir_items: List[str]) -> None: + '''SystemdFileFactory.DepDir.update_config_files() + + Adds all items from the dir into the config_files list''' + + self.config_files.extend(dir_items) + + + def update_wants_deps(self, dir_items: List[str]) -> None: + '''SystemdFileFactory.DepDir.update_wants_deps() + + Adds all items from the dir to both the wants_deps and all_deps lists. This is so + we can independently keep track of which units are wanted and which are required.''' + + self.wants_deps.extend(dir_items) + self.all_deps.extend(dir_items) + + + def update_requires_deps(self, dir_items: List[str]) -> None: + '''SystemdFileFactory.DepDir.update_requires_deps() + + Adds all items from the dir to both the requires_deps and all_deps lists. This is so + we can independently keep track of which units are wanted and which are required.''' + + self.requires_deps.extend(dir_items) + self.all_deps.extend(dir_items) + + + def record(self) -> Dict[str, List[str]]: + '''SystemdFileFactory.DepDir.record() + + Creates a dictionary of metadata containing the file_type, dependency_folder_paths, + dependencies, and any other lists that have been populated belonging to this object.''' + + dep_dir_data = { + 'file_type': 'dep_dir', + 'dependency_folder_paths': self.dep_dir_paths, + 'dependencies': self.all_deps + } + + logging.vdebug( f'Initial dependency directory structure created:\n{dep_dir_data}' ) + + if len(self.config_files) > 0: + dep_dir_data['config_files'] = self.config_files + if len(self.wants_deps) > 0: + dep_dir_data['Wants'] = self.wants_deps + if len(self.requires_deps) > 0: + dep_dir_data['Requires'] = self.requires_deps + + logging.debug( f'Final dependency directory structure being returned:\n{dep_dir_data}' ) + + return { 'metadata': dep_dir_data } + + + +class SymLink: + + + def __init__(self, remote_path: str, path: str, unit_name: str) -> None: + '''The SystemdFileFactory.SymLink class is designed to make sure that each unit file is its own validated object. + The intent is to make sure that there are no unknown or weird unit file types slipping through + the cracks, and to verify that we know every unit file type that is being recorded. If there are + any weird unit file extensions or unit file types being used we should investigate them.''' + + sl_full_path = f'{remote_path}{path}{unit_name}' + self.remote_path = remote_path + + if Path(sl_full_path).is_symlink(): + self.name = unit_name + self.path = path + + self.target_unit: str = readlink(sl_full_path).split('/')[-1] + self.target_path: str = self.get_target_path(sl_full_path) + + else: + logging.warning( f'{sl_full_path} is not a sym link but is being parsed as one.' ) + + + def get_target_path(self, sl_full_path: str) -> str: + '''SystemdFileFactory.SymLink.get_target_path() + + Checks to see if the target that the symbolic link is pointing to is specified through absolute + or relative pathing, and converts it to an absolute path. The resulting path will be specified as + the system path the sym link WOULD resolve to if the system were booting up, and the remote path + will be dropped if it is present.''' + + sl_ret_path = Path( readlink(sl_full_path) ) + + if not sl_ret_path.is_absolute(): + current_dir = getcwd() + chdir( Path(sl_full_path).parent ) + absolute_path = path.abspath(sl_ret_path) + sl_ret_path = Path(absolute_path) + chdir(current_dir) + + if self.remote_path != '' and self.remote_path in str(sl_ret_path): + sl_ret_path = str(sl_ret_path.parent).split(self.remote_path)[-1] + + sl_ret_path = str(sl_ret_path).split( f'/{self.target_unit}' )[0] + + return f'{sl_ret_path}/' + + + def record(self) -> Dict[str, Any]: + '''SystemdFileFactory.SymLink.record() + + Creates a dictionary of metadata containing the file_type, sym link + information, and the dependency that has been recorded to this object.''' + + sym_link_data = {'file_type': 'sym_link'} + sym_link_data['sym_link_path'] = self.path + sym_link_data['sym_link_unit'] = self.name + sym_link_data['sym_link_target_path'] = self.target_path + sym_link_data['sym_link_target_unit'] = self.target_unit + sym_link_data['dependencies'] = [self.target_unit] + + logging.debug( f'Symbolic link structure created:\n{sym_link_data}' ) + + return { 'metadata': sym_link_data } + + + +class UnitFile: + + + def __init__(self, path: str, unit_file: str) -> None: + '''The SystemdFileFactory.UnitFile class is designed to ensure that each unit file is its own validated object. + The intent is to make sure that there are no unknown or weird unit file types or unit options slipping + through the cracks, and to verify that we know every unit file type that and option that is being + recorded. If there are any weird unit file extensions, unit types, or unit file options being used we + should investigate them.''' + + self.name = unit_file + self.path = path + self.unit_type = self.get_unit_type(self.name) + self.unit_struct: Dict[str, List[str]] = { 'metadata': { 'file_type': 'unit_file'} } + + + def get_unit_type(self, unit_name: str) -> str: + '''SystemdFileFactory.UnitFile.get_unit_type() + + Designed to make sure that each unit file has a valid suffix. The intent is to make sure that there are no unknown + unit file types slipping through the cracks, and to verify that we are aware of all unit file types being recorded. + If there are any unknown unit file extensions or unit file types being used we should investigate them.''' + + if unit_name.split('.')[-1] in possible_unit_opts: + logging.debug( f'"{unit_name}" is a valid unit file type' ) + return unit_name.split('.')[-1] + else: + logging.warning( f'"{self.path}{unit_name}" is an invalid or unknown unit file type, returning "target" as the type instead' ) + return 'target' + + + def update_unit_file(self, remote_path: str, path: str, unit_file: str) -> None: + '''SystemdFileFactory.UnitFile.update_unit_file() + + Opens the specified unit file and parses it line by line. If an '=' is encountered on any of the lines it considers this + an option line, and will check the option and it's associated arguments to make sure they are valid before recording.''' + + with open( f'{remote_path}{path}{unit_file}', 'r' ) as in_file: + for line in in_file: + + if '=' in line and '#' != line[0]: + + ''' Some unit files have newline escapes '\\' for exec start opts. This combines them + until no more newline escapes '\\' are encountered at the end of the command/line. + Otherwise, the newlines won't be recorded as arguments for that option and we will + lose part of the commands. ''' + + if line[-2] == '\\': + extra_line_marker = True + while extra_line_marker == True: + line = line.rstrip('\\\n') + in_file.readline() + if line[-2] != '\\': + extra_line_marker = False + + line_option = line.rstrip('\n').split('=')[0] + arguments = '='.join( line.rstrip('\n').split('=')[1:] ) + + self.option = self.check_option(line_option) + self.arguments = self.format_arguments(line_option, arguments) + + if self.option in self.unit_struct: + self.unit_struct[self.option].extend(self.arguments) + else: + self.unit_struct.update({ self.option: self.arguments }) + + + def check_option(self, line_option: str) -> None: + '''SystemdFileFactory.UnitFile.check_option() + + Checks that each option being parsed is an option accepted by systemd. Valid options are contained in + the many section option lists in the unit_file_lists.py file, and may need to be updated periodically.''' + + for option_list in possible_unit_opts[self.unit_type]: + if line_option in option_list: + return line_option + + logging.warning( f'"{line_option}" is not a valid option for {self.unit_type} units. Please investigate "{line_option}" option in {self.name}' ) + return line_option + + + def format_arguments(self, line_option: str, line_arguments: str) -> None: + '''SystemdFileFactory.UnitFile.format_arguments() + + Checks to see if valid options have space delimited arguments or not and records arguments based on this specification.''' + + if line_option in space_delim_opts: + return line_arguments.split() + + return [line_arguments] + + + def check_implicit_dependencies(self, unit_type: str) -> None: + '''SystemdFileFactory.UnitFile.check_implicit_dependencies() + + Handle all implicit deps that are created based on the unit file type. Default + deps are automatically placed in the unit file upon creation, implicit deps are not. + + - TODO: look at escaping logic, slices, device paths, and auto/mount paths and check for paths on the system?''' + + # systemd.automount(5), automatic dependencies, implicit dependencies + if unit_type == 'automount': + self.unit_struct['metadata'].update({ 'Before': [ f'{self.name.split(".")[0]}.mount' ] }) + + # systemd.path(5), description, para 3 + elif unit_type == 'path' and 'Unit' not in self.unit_struct: + self.unit_struct['metadata'].update({ + 'iPath_for': [ f'{self.name.split(".")[0]}.service' ], + 'Before': [ f'{self.name.split(".")[0]}.service' ] + }) + + # systemd.socket(5), description, para 4 + elif unit_type == 'socket' and 'Service' not in self.unit_struct: + self.unit_struct['metadata'].update({ 'iSocket_of': [ f'{self.name.split(".")[0]}.service' ] }) + + # systemd.socket(5), automatic dependencies, implicit dependencies + elif unit_type == 'socket' and 'BindToDevice' in self.unit_struct: + self.unit_struct['metadata'].update({ 'BindsTo': [ self.unit_struct['BindsToDevice'] ] }) + + # systemd.service(5), automatic dependencies, implicit dependencies, bullet 1 + elif unit_type == 'service' and 'Type' in self.unit_struct: + if self.unit_struct['Type'] == 'dbus': + self.unit_struct['metadata'].update({ 'Requires': ['dbus.socket'], 'After': ['dbus.socket'] }) + + # systemd.service(5), automatic dependencies, implicit dependencies, bullet 2 + elif unit_type == 'service' and 'Sockets' in self.unit_struct: + socket_unit_list = [ unit for unit in self.unit_struct['Sockets'].split(' ') ] + self.unit_struct['metadata'].update({ + 'Wants': socket_unit_list, + 'After': socket_unit_list + }) + + # systemd.timer(5), description, para 3/ systemd.timer(5), implicit dependencies, bullet 1 + elif unit_type == 'timer' and 'Unit' not in self.unit_struct: + self.unit_struct['metadata'].update({ + 'iTimer_for': [ f'{self.name.split(".")[0]}.service' ], + 'Before': [ f'{self.name.split(".")[0]}.service' ] + }) + + # systemd.exec(5), implicit dependencies, bullet 4 + elif 'TTYPath' in self.unit_struct: + self.unit_struct['metadata'].update({ 'After': ['systemd-vconsole-setup.service'] }) + + # systemd.exec(5), implicit dependencies, bullet 5 + elif 'LogNamespace' in self.unit_struct: + self.unit_struct['metadata'].update({ 'Requires': ['systemd-journald@.service'] }) + + # systemd.resource-control(5), implicit dependencies, bullet 1 + elif 'Slice' in self.unit_struct: + self.unit_struct['metadata'].update({ + 'Requires': [ self.unit_struct['Slice'] ], + 'After': [ self.unit_struct['Slice'] ] + }) + + + def record(self) -> Dict[str, List[str]]: + '''SystemdFileFactory.UnitFile.record() + + Creates a dictionary of all valid options that were encountered while parsing the unit file, + along with their associated arguments. The metadata dict contains all implicit dependencies.''' + + logging.vdebug( f'Final unit file structure being returned:\n{self.unit_struct}' ) + + return self.unit_struct diff --git a/lib/systemd_mapping.py b/lib/systemd_mapping.py new file mode 100644 index 0000000..f88ed28 --- /dev/null +++ b/lib/systemd_mapping.py @@ -0,0 +1,379 @@ +''' +systemd_mapping.py +Authors: Mike Huettel, Jason M. Carter +Date: December 2023 +Version: 1.0 + +Licensed under the Apache License, Version 2.0 (the "License") +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +Contributors: + Oak Ridge National Laboratory + +Description: This is the main logic for the tool that searches for which files and folders to parse. + After files and folders are found they will be passed to sysd_obj_parser to be validated and then + recorded to the master structure dictionary. After the master struct is built, systemd snapshot + can run the dependency mapping function. The dependency mapping function starts with the origin + unit file ('default.target' by default) and searches through the master struct to find any and all + dependencies that are created by that unit. It does this dependency mapping for each dependency that + is created unitl none remain. For more information, including struct mappings, see doc strings. +''' + +import logging +import re + +from os import readlink +from subprocess import run +from pathlib import Path +from typing import List, Dict, Set, Tuple + +from lib.unit_file_lists import sys_unit_paths, command_directives, ms_only_keys +from lib.sysd_obj_parser import SystemdFileFactory +from lib.dep_obj_parser import DepMapUnit + +master_struct = {} +'''holds information about each individual unit file and folder within the +default systemd system paths''' + + +def get_bin_path(cmd_string: str) -> str: + '''Extracted out of check_binaries to make it usable for dep map. + + TODO: + - Check to see if binary is a path. If not we need to get the path variables that should be set and + try to figure out which one contains the bin we are looking for.''' + + binary = cmd_string.split()[0] + binary = remove_prefixes(binary) + return binary + + +def remove_prefixes(binary: str) -> str: + '''This function removes all of the prefixes from a command directive option's corresponding argument. + "@", "-", ":", and one of "+"/"!"/"!!" may be used together and they can appear in any order. However, + only one of "+", "!", "!!" may be used at a time. For more info see systemd.service (5) man page.''' + + cmd_prefixes = ( '@', '-', ':', '+', '!' ) + + i = 0 + while i < len(binary): + if binary[i] in cmd_prefixes: + i += 1 + else: + # If we reach the end of the cmd_prefixes exit the loop + break + + return binary[i:] + + +def get_bin_libs(remote_path: str, binary: str) -> Set: + '''Use objdump to enumerate the given binary and return that are required by it. This function + is ONLY meant to be used when building the master struct. If this function is used outside of the + machine being snapshot, it WILL either return nothing or return false info based on the machine being used. + + TODO: + - Create version check and use check_output for versions older than 3.7''' + + lib_regex = re.compile('\s*NEEDED\s+(.+)') + + output = run( ['objdump', '-p', f'{remote_path}{binary}' ], capture_output=True, text=True ) + return set( lib_regex.findall(output.stdout) ) + + +def get_bin_strings(remote_path: str, binary: str) -> Tuple[Set, Set]: + '''Use strings to enumerate the given binary and return paths and files that are referenced. This function + is ONLY meant to be used when building the master struct. If this function is used outside of the + machine being snapshot, it WILL either return nothing or return false info based on the machine being used. + + TODO: + - Create version check and use check_output for versions older than 3.7''' + + file_ext_list = ['cfg','conf','ini','log','exe'] + file_regex = re.compile( '^.+\.({})$'.format( '|'.join(file_ext_list) ) ) + path_regex = re.compile('^/\w+(/[\w\.-]*)+$') + # begin with / followed by at least 1 alphanum char with at least one more / followed by alphanum, '.', or '-' chars any num of times + + output = run( [ 'strings', f'{remote_path}{binary}' ], capture_output=True, text=True ) + files = { file.split('=')[-1] for file in filter( file_regex.match, output.stdout.split() ) } + strings = { string.split('=')[-1] for string in filter( path_regex.match, output.stdout.split() ) } + + # Since path regex will match on the same strings as file_regex, remove files from strings + strings.symmetric_difference_update(files) + + return (files, strings) + + +def check_binaries(remote_path: str, unit_struct: Dict[str, List]) -> Dict[str, Dict[str, Set]]: + '''This function checks for unit file command options and inspects the binaries specified in the + command to get a listing of all of the libraries, config and log files, and other potentially + interesting filepath strings that are specified in the binary.''' + + exec_deps = { 'libraries': {}, 'files': {}, 'strings': {} } + + for option in unit_struct: + if option in command_directives: + for cmd in unit_struct[option]: + if len(cmd) < 1: + continue + + binary = get_bin_path(cmd) + + if (binary not in master_struct['libraries'] and + binary not in exec_deps['libraries']): + + exec_deps['libraries'].update({ binary: get_bin_libs(remote_path, binary) }) + bin_files, bin_strings = get_bin_strings(remote_path, binary) + exec_deps['files'].update({ binary: bin_files }) + exec_deps['strings'].update({ binary: bin_strings }) + + return exec_deps + + +def map_systemd_full(remote_path: str, log: logging) -> dict: + '''Map Systemd Full is designed to create a master_struct that will store all unit files on the system + regardless of dependencies. Ideally this will be used in conjunction with the output file option to + allow users to create a "outfile_master_struct.json" file that can be referenced in the future to map + dependencies without having to create a new master_struct every time. + + The function iterates through all of the default systemd system paths, and checks to see if each one is + present. If the system path is present, all files and dependency folders will be established + independently as a SystemdFile object, and then, depending on the file type, will be parsed as a + dependency directory, symbolic link, or unit file. + + If a unit file has command directives that will run an binary upon getting started, we will record + additional info on the binary in the libraries and files dictionaries. These are maps from the + binaries found in the unit file command directives to libraries and files that the binary requires. + These are recorded independently of the unit file entries to avoid duplication. + + Lastly, the function will update the master_struct with the parsed info. The final product should follow + the format detailed below: + + master_struct = { + 'remote_path': '/some/remote/file/system/root/dir', + 'libraries': { + '/usr/bin/exe1': ['lib1', 'lib2', 'lib3'], + '/bin/exe2': ['lib3', 'lib4'] + }, + 'files': { + '/usr/bin/exe1': ['file.config'], + '/bin/exe2': ['file.ini', 'file.log'] + }, + 'strings': { + '/bin/exe2': ['/var/log/exe2/'] + }, + 'wants folder example' : { + 'metadata': { + 'file_type' : 'dep_dir', + 'dependency_folder_paths' : ['/sys/path/to/dep/dir'], + 'dependencies' : ['unitA', 'unitB', 'unitC'], + 'Wants' : [ 'Wants', 'field', 'Units' ] + } + }, + 'symbolic link example' : { + 'metadata' : { + 'file_type': 'sym_link', + 'sym_link_path' : path, + 'sym_link_unit' : 'unit.target', + 'sym_link_target_path' : target_path, + 'sym_link_target_unit' : target_unit, + 'dependencies' : target_unit + } + }, + 'unit file example' : { + 'metadata' : { + 'file_type': 'unit_file' + }, + unit_file_option : option_arguments, + ... + } + } + ''' + + log.info('Beginning recording of all files in Systemd folders.') + master_struct.update({ + 'remote_path': remote_path, + 'libraries': {}, + 'files': {}, + 'strings': {} + }) + + UnitFactory = SystemdFileFactory(remote_path) + + # Prevent duplicate dir traversal if /lib is sym linked to /usr/lib + try: + if readlink( f'{remote_path}/lib' ) == f'usr/lib': + sys_unit_paths.remove('/lib/systemd/system/') + except OSError: + log.debug('/lib is not sym linked to /usr/lib. Retaining /lib/systemd/system system path.') + + for sys_path in sys_unit_paths: + check_path = Path( f'{remote_path}{sys_path}' ) + + if check_path.exists(): + for unit_file_fp in [files for files in check_path.glob('**/*')]: + + log.debug( f'Sending {unit_file_fp} for processing' ) + + current_unit = str(unit_file_fp).split('/')[-1] + unit_path = str(unit_file_fp.parents[0]) + '/' + + # If there is a remote path, remove it to avoid duplication + if remote_path != '' and remote_path in unit_path: + unit_path = unit_path.split(remote_path)[-1] + + # Reset unit file info + unit_file = None + unit_file = UnitFactory.parse_file(unit_path, current_unit) + + log.debug( f'Finished recording {unit_file_fp}' ) + + log.debug( f'Checking for binaries, libraries, and files required by {unit_file_fp}' ) + + bin_requirements = check_binaries(remote_path, unit_file) + for requirement_type in bin_requirements: + # requirement_type is referencing either the libraries, files, or strings dict + + for binary in bin_requirements[requirement_type]: + # binary is referencing the bin dicts w/in the Lib, File, or String dicts + master_struct[requirement_type].update({ binary: bin_requirements[requirement_type][binary] }) + + log.debug( f'Finished getting binaries, libraries, and files required by {unit_file_fp}' ) + + master_struct.update({ f'{unit_path}{current_unit}': unit_file }) + + log.info( f'Finished recording all Systemd unit files into Master Structure' ) + log.vdebug( f'\n\n{master_struct}' ) + + return master_struct + + +dependency_map = {} +'''dictionary that will hold all of the dependency relationships for a given master_struct +json. Both "forward" and "backward" relationships will be established and recorded here.''' + + +def map_dependencies(master_struct: Dict, origin_unit: str, log: logging) -> dict: + '''This function uses the information in the master struct to create a dependency list that users can view + and record to investigate dependencies that are being created when the system is booting up. The function + will start with one unit ('default.target') and find all of the dependencies it creates. Once it is done + recording the dependencies for a unit, it checks to see if there are any dependencies that have not yet + been recorded, and then records any that are missing one at a time. Dependency tuples are created upon + each iteration in order to maintain a backwards mapping of dependencies (e.g. what created the deps). This + is very useful when investigating the startup processes of something failing to boot properly. + + To record the unit files, the function unpacks a dependency tuple and uses it to create a DepMapUnit object. + Once this object is created, any entries in the master struct that are associated with that unit file will + be parsed and recorded. If the master struct entry is a sym link entry, the dependency tuples will be + created immediately after being parsed and deduplicated in order to maintain the full path to the sym link. + Otherwise, all of the master struct entries, as well as previous dep map entires, that are associated with + the unit file in question will be parsed, deduplicated, and then recorded to the dependency map. Lastly, + once everything is recorded the function will use the entry to create unique dependency tuples if needed. + + Unit files and their dependencies will have following structure. Note that the first unit pointed to will + not have any parents or reverse dependency mappings because no dependency tuples have been created for it: + + dependency_map = { + 'first.unit' : { + 'unit_file': 'default.target', + 'parents' : ['None'], + 'rev_deps' : ['None'], + 'dependencies' : ['multi-user.target', 'display-manager.serivce'] + }, + 'all_other.units' : { + 'unit_file' : 'multi-user.target', + 'parents' : 'default.target', + 'rev_deps' : ['wanted_by', 'required_by', 'etc.'] + 'Requires' : ['requires.units'], + 'Wants' : ['wants.units'], + 'dependencies' : [...] + } + } + ''' + + log.info('Starting the dependency relationship mapping...') + log.vdebug( f'Searching for dependency relationships in:\n\n{master_struct}' ) + + unrecorded_dependencies: List[tuple] = [(origin_unit, 'None', 'None')] + + recorded_dependencies: List[tuple] = [] + new_dep_tups: List[tuple] = [] + + while len(unrecorded_dependencies) > 0: + + current_unit, parent_unit_path, dep_type = unrecorded_dependencies[0] + new_dep_unit = DepMapUnit(current_unit, parent_unit_path, dep_type) + + log.debug( f'searching master struct for {current_unit} to satisfy {unrecorded_dependencies[0]}' ) + + for sysd_obj_key in master_struct: + if current_unit in dependency_map: + log.debug( f'{current_unit} is already recorded. Copying old entry instead re-searching master struct' ) + new_dep_unit.load_from_dep_map( dependency_map[current_unit] ) + break + + # skip parsing non-unit file keys. This list is located in unit file lists + if sysd_obj_key in ms_only_keys: + continue + + # Dep dir names will trigger false positives on all units contained in the dir unless we only look + # at the last item in the filepath split. (basic.target.wants/unit.file will trigger on basic.target) + if new_dep_unit.unit_name in sysd_obj_key.split('/')[-1]: + log.debug( f'Found {current_unit} in {sysd_obj_key}' ) + new_dep_unit.load_from_ms( master_struct[sysd_obj_key] ) + + if master_struct[sysd_obj_key]['metadata']['file_type'] == 'sym_link': + new_dep_tups.extend( new_dep_unit.create_dep_tups(sysd_obj_key) ) + + + dependency_map.update({ new_dep_unit.unit_name: new_dep_unit.record() }) + new_dep_tups.extend( new_dep_unit.create_dep_tups(current_unit) ) + + # check for bins so we can add libraries, files, and strings to the unit entry + commands = new_dep_unit.get_commands() + for command in commands: + binary = get_bin_path(command) + if 'libraries' not in dependency_map[current_unit]: + dependency_map[current_unit].update({ + 'binaries': set(), + 'libraries': set(), + 'files': set(), + 'strings': set() + }) + dependency_map[current_unit]['binaries'].update({ binary }) + dependency_map[current_unit]['libraries'].update( master_struct['libraries'][binary] ) + dependency_map[current_unit]['files'].update( master_struct['files'][binary] ) + dependency_map[current_unit]['strings'].update( master_struct['strings'][binary] ) + + log.debug( f'info recorded for {new_dep_unit.unit_name}:' ) + log.vdebug( f'{new_dep_unit.record()}\n' ) + + for tups in new_dep_tups: + if tups[2] == 'sym_linked_from' and '/' not in tups[1]: + log.debug( f'discarding {tups} because it is a sym link duplicate.' ) + elif tups in recorded_dependencies or tups in unrecorded_dependencies: + log.debug( f'skipping recording dep tup: {tups}' ) + else: + log.debug( f'adding {tups} to unrecorded dependencies' ) + unrecorded_dependencies.append(tups) + + # Clean up and prepare for next iteration + new_dep_tups = [] + recorded_dependencies.append(unrecorded_dependencies.pop(0)) + + log.vdebug( f'\nrecorded dependencies: {recorded_dependencies}' ) + log.vdebug( f'unrecorded dependencies: {unrecorded_dependencies}' ) + log.vdebug( f'\n\nnew dependency map: {dependency_map}\n' ) + + log.info('Finished recording all dependency relationships') + log.vdebug( f'\n\n{dependency_map}' ) + + return dependency_map diff --git a/lib/unit_file_lists.py b/lib/unit_file_lists.py new file mode 100644 index 0000000..6ff3118 --- /dev/null +++ b/lib/unit_file_lists.py @@ -0,0 +1,869 @@ +''' +unit_file_lists.py +Authors: Mike Huettel, Jason M. Carter +Date: December 2023 +Version: 1.0 + +Licensed under the Apache License, Version 2.0 (the "License") +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +Contributors: + Oak Ridge National Laboratory + +Description: This file holds all of the file lists that are used by the tool. This is where the tool + goes to find which options are available to which unit files, as well as which sections are available + to which unit types. For more information, check the doc strings of a specific list below. +''' + +sys_unit_paths = [ + '/etc/systemd/system.control/', + '/run/systemd/system.control/', + '/run/systemd/transient/', + '/run/systemd/generator.early/', + '/etc/systemd/system/', + '/etc/systemd/system.attached/', + '/run/systemd/system/', + '/run/systemd/system.attached/', + '/run/systemd/generator/', + '/lib/systemd/system/', + '/usr/local/lib/systemd/system', + '/usr/lib/systemd/system/', + '/run/systemd/generator.late/' +] +'''List of all of the system paths systemd will check for unit files''' + +usr_unit_paths = [ + '~/.config/systemd/user.control/', + '$XDG_RUNTIME_DIR/systemd/user.control/', + '$XDG_RUNTIME_DIR/systemd/transient/', + '$XDG_RUNTIME_DIR/systemd/generator.early/', + '~/.config/systemd/user/', + '$XDG_CONFIG_DIRS/systemd/user/', + '/etc/systemd/user/', + '$XDG_RUNTIME_DIR/systemd/user/', + '/run/systemd/user/', + '$XDG_RUNTIME_DIR/systemd/generator/', + '$XDG_DATA_HOME/systemd/user/', + '$XDG_DATA_DIRS/systemd/user/', + '/usr/lib/systemd/user/', + '$XDG_RUNTIME_DIR/systemd/generator.late/' +] +'''List of all the user paths systemd will check for unit files''' + +unit_generic_opts = [ + 'Description', + 'Documentation', + 'Before', + 'After', + 'Wants', + 'Conflicts', + 'Requires', + 'Requisite', + 'BindsTo', + 'PartOf', + 'Upholds', + 'OnSuccess', + 'OnFailure', + 'PropagatesReloadTo', + 'ReloadPropagatedFrom', + 'PropagatesStopTo', + 'StopPropagatedFrom', + 'JoinsNamespaceOf', + 'RequiresMountsFor', + 'OnFailureJobMode', + 'IgnoreOnIsolate', + 'StopWhenUnneeded', + 'RefuseManualStart', + 'RefuseManualStop', + 'AllowIsolate', + 'DefaultDependencies', + 'CollectMode', + 'FailureAction', + 'FailureActionExitStatus', + 'SuccessAction', + 'SuccessActionExitStatus', + 'JobTimeoutSec', + 'JobRunningTimeoutSec', + 'JobTimeoutAction', + 'JobTimeoutRebootArgument', + 'StartLimitIntervalSec', + 'StartLimitInterval', + 'StartLimitBurst', + 'StartLimitAction', + 'RebootArgument', + 'SourcePath' +] +'''This is a full listing of generic unit options. +This is used to parse the unit file [Unit] section.''' + +unit_cond_assert_opts = [ + 'ConditionArchitecture', + 'ConditionFirmware', + 'ConditionVirtualization', + 'ConditionHost', + 'ConditionKernelCommandLine', + 'ConditionKernelVersion', + 'ConditionCredential', + 'ConditionEnvironment', + 'ConditionSecurity', + 'ConditionCapability', + 'ConditionACPower', + 'ConditionNeedsUpdate', + 'ConditionFirstBoot', + 'ConditionPathExists', + 'ConditionPathExistsGlob', + 'ConditionPathIsDirectory', + 'ConditionPathIsSymbolicLink', + 'ConditionPathIsMountPoint', + 'ConditionPathIsReadWrite', + 'ConditionPathIsEncrypted', + 'ConditionDirectoryNotEmpty', + 'ConditionFileNotEmpty', + 'ConditionFileIsExecutable', + 'ConditionUser', + 'ConditionGroup', + 'ConditionControlGroupController', + 'ConditionMemory', + 'ConditionCPUs', + 'ConditionCPUFeature', + 'ConditionOSRelease', + 'ConditionMemoryPressure', + 'ConditionCPUPressure', + 'ConditionIOPressure', + 'AssertArchitecture', + 'AssertVirtualization', + 'AssertHost', + 'AssertKernelCommandLine', + 'AssertKernelVersion', + 'AssertCredential', + 'AssertEnvironment', + 'AssertSecurity', + 'AssertCapability', + 'AssertACPower', + 'AssertNeedsUpdate', + 'AssertFirstBoot', + 'AssertPathExists', + 'AssertPathExistsGlob', + 'AssertPathIsDirectory', + 'AssertPathIsSymbolicLink', + 'AssertPathIsMountPoint', + 'AssertPathIsReadWrite', + 'AssertPathIsEncrypted', + 'AssertDirectoryNotEmpty', + 'AssertFileNotEmpty', + 'AssertFileIsExecutable', + 'AssertUser', + 'AssertGroup', + 'AssertControlGroupController', + 'AssertMemory', + 'AssertCPUs', + 'AssertCPUFeature', + 'AssertOSRelease', + 'AssertMemoryPressure', + 'AssertCPUPressure', + 'AssertIOPressure' +] +'''This is a full list of generic unit file conditions/assertions. +This is also used to parse the unit file [Unit] section, but these +are grouped separately due to usage similiarty and number of items.''' + +unit_install_opts = [ + 'Alias', + 'WantedBy', + 'RequiredBy', + 'Also', + 'DefaultInstance' +] +'''This is a list of all generic unit file install options. +This is used to parse the unit file [Install] section.''' + +serv_unit_opts = [ + 'Type', + 'ExitType', + 'RemainAfterExit', + 'GuessMainPID', + 'PIDFile', + 'BusName', + 'ExecStart', + 'ExecStartPre', + 'ExecStartPost', + 'ExecCondition', + 'ExecReload', + 'ExecStop', + 'ExecStopPost', + 'RestartSec', + 'TimeoutStartSec', + 'TimeoutStopSec', + 'TimeoutAbortSec', + 'TimeoutSec', + 'TimeoutStartFailureMode', + 'TimeoutStopFailureMode', + 'RuntimeMaxSec', + 'RuntimeRandomizedExtraSec', + 'WatchdogSec', + 'Restart', + 'SuccessExitStatus', + 'RestartPreventExitStatus', + 'RestartForceExitStatus', + 'PermissionsStartOnly', + 'RootDirectoryStartOnly', + 'NonBlocking', + 'NotifyAccess', + 'Sockets', + 'FileDescriptorStoreMax', + 'USBFunctionDescriptors', + 'USBFunctionStrings', + 'OOMPolicy', + 'OpenFile', + 'ReloadSignal' +] +''' + This is a list of options that are unit.service specific. This will be used to parse the unit files. + Service unit files may include [Unit] and [Install] sections, and must include a [Service] section. + + Implicit dependencies: + - Type=Dbus automatically sets Requires= and After= on dbus.socket + - If service is activated by file.socket, service will automatically set After=file.socket + - Also subject to implicit rules from .exec and .resource-control units + + Default dependencies: + - Requires= and After=sysinit.target + - After=basic.target + - Before= and Conflicts=shutdown.target +''' + +''' + Target units exist only to group units via dependencies. As such, no unit.target file specific options are supported. + Target unit files may include [Unit] and [Install] sections. See systemd.target man page for more info. + + Implicit dependencies: + - None + + Default dependencies: + - Adds After= to all unit files that this unit Wants= and Requires= + - Before= and Conflicts=shutdown.target +''' + +''' + Device units have no file specific options. They may use the generic [Unit] and [Install] sections and + options, but there is no [Device] section. Device units are named after the sys and dev paths they control. + See systemd.device man page for more info. + + Implicit dependencies: + - None on device files, but some other files may have deps on this file + + Default dependencies: + - None +''' + +''' + Slice units are like central repos to control system resource usage. No unit.slice file specific options are supported, + but slice unit files may include [Unit] and [Install] sections, and may have a [Slice] section that enables the + use of resource-control unit options. See systemd.slice man page for more info. + + Implicit dependencies: + - After= and Requires=parent.unit + + Default dependencies: + - Before= and Conflicts=shutdown.target +''' + +sock_unit_opts = [ + 'ListenStream', + 'ListenDatagram', + 'ListenSequentialPacket', + 'ListenFIFO', + 'ListenSpecial', + 'ListenNetlink', + 'ListenMessageQueue', + 'ListenUSBFunction', + 'SocketProtocol', + 'BindIPv6Only', + 'Backlog', + 'BindToDevice', + 'SocketUser', + 'SocketGroup', + 'SocketMode', + 'DirectoryMode', + 'Accept', + 'Writable', + 'FlushPending', + 'MaxConnections', + 'MaxConnectionsPerSource', + 'KeepAlive', + 'KeepAliveTimeSec', + 'KeepAliveIntervalSec', + 'KeepAliveProbes', + 'NoDelay', + 'Priority', + 'DeferAcceptSec', + 'ReceiveBuffer', + 'SendBuffer', + 'IPTOS', + 'IPTTL', + 'Mark', + 'ReusePort', + 'SmackLabel', + 'SmackLabelIPIn', + 'SmackLabelIPOut', + 'SELinuxContextFromNet', + 'PipeSize', + 'MessageQueueMaxMessages', + 'MessageQueueMessageSize', + 'FreeBind', + 'Transparent', + 'Broadcast', + 'PassCredentials', + 'PassSecurity', + 'PassPacketInfo', + 'Timestamping', + 'TCPCongestion', + 'ExecStartPre', + 'ExecStartPost', + 'ExecStopPre', + 'ExecStopPost', + 'TimeoutSec', + 'Service', + 'RemoveOnStop', + 'Symlinks', + 'FileDescriptorName', + 'TriggerLimitIntervalSec', + 'TriggerLimitBurst' +] +''' + This is a list of options that are unit.socket specific. This will be used to parse the unit files. + Socket unit files may include [Unit] and [Install] sections, and may include a [Socket] section that + enables the use of exec, kill, and resource-control unit options as well as the listed socket specific options. + + Implicit dependencies: + - .socket unit files automatically start their matching .service unit unless Service= is set + - Before=matching.service + - Requires= and After= on all mount units necessary to access system paths that are referred to. + - Socket units using BindToDevice= will gain a BindsTo= and After= dependency on the device unit specified. + + Default dependencies: + - Before=sockets.target + - Requires= and After=sysinit.target + - Before= and Conflicts=shutdown.target +''' + +mnt_unit_opts = [ + 'What', + 'Where', + 'Type', + 'Options', + 'SloppyOptions', + 'LazyUnmount', + 'ReadWriteOnly', + 'ForceUnmount', + 'DirectoryMode', + 'TimeoutSec' +] +''' + This is a list of options that are unit.mount specific. This will be used to parse the unit files. + Mount unit files may include [Unit] and [Install] sections, and must include a [Mount] section that + enables the use of exec, kill, and resource-control unit options as well as the listed mount specific options. + Systemd will turn each entry in the fstab into a .mount unit dynamically at runtime. + + Implicit dependencies: + - Requires=parent.mount and Before/After=parent/child.mount + - Block device units gain BindsTo= and After= on fs unit files + - Before= and Wants=systemd-quotacheck.service and quotaon.service if fs quota is enabled + + Default dependencies: + - Before= and Conflicts=umount.target + - After=local-fs-pre.target + - Before=local-fs.target + - After=remote-fs-pre.target, network.target and network-online.target if mount is a network mount + - Before=remote-fs.target if mount is a network mount +''' + +automnt_unit_opts = [ + 'Where', + 'ExtraOptions', + 'DirectoryMode', + 'TimeoutIdleSec' +] +''' + This is a list of options that are unit.automount specific. This will be used to parse the unit files. + Automount unit files may include [Unit], [Install], and [Automount] sections, and must be named after + the automount directories they control, as well as a matching mount unit file. + + Implicit dependencies: + - Before=unit.mount that will be activated + + Default dependencies: + - Before= and Conflicts=umount.target + - Before=local-fs.target + - After=local-fs-pre.target +''' + +swap_unit_opts = [ + 'What', + 'Priority', + 'Options', + 'TimeoutSec' +] +''' + This is a list of options that are unit.swap specific. This will be used to parse the unit files. + Swap unit files may include [Unit] and [Install] sections, and may include a [Swap] section that + enables the use of exec, kill, and resource-control unit options as well as the listed swap specific options. + + Implicit dependencies: + - After= and BindsTo=unit_that_activates_this_unit + + Default dependencies: + - Before= and Conflicts=umount.target + - Before=swap.target +''' + +path_unit_opts = [ + 'PathExists', + 'PathExistsGlob', + 'PathChanged', + 'PathModified', + 'DirectoryNotEmpty', + 'Unit', + 'MakeDirectory', + 'DirectoryMode', + 'TriggerLimitIntervalSec', + 'TriggerLimitBurst' +] +''' + This is a list of options that are unit.path specific. This will be used to parse the unit files. + Path unit files may include [Unit] and [Install] sections, and must include a [Path] section, which + enables the use of the listed path specific options. + + Implicit dependencies: + - ordering dependency created on mount paths above this unit + - Before=unit_to_activate + + Default dependencies: + - Before=paths.target + - After= and Requires=sysinit.target + - Before= and Conflicts=shutdown.target +''' + +timer_unit_opts = [ + 'OnActiveSec', + 'OnBootSec', + 'OnStartupSec', + 'OnUnitActiveSec', + 'OnUnitInactiveSec', + 'OnCalendar', + 'AccuracySec', + 'RandomizedDelaySec', + 'FixedRandomDelay', + 'OnClockChange', + 'OnTimezoneChange', + 'Unit', + 'Persistent', + 'WakeSystem', + 'RemainAfterElapse' +] +''' + This is a list of options that are unit.timer specific. This will be used to parse the unit files. + Timer unit files may include [Unit] and [Install] sections, and must include a [Timer] section, which + enables the use of the listed timer specific options. + Services with the same name as timer units will be automatically activated when the matching + unit.timer file is activated. + + Implicit dependencies: + - Before=matching_unit.service + + Default dependencies: + - After= and Requies=sysinit.target + - Before=timers.target + - Before= and Conflicts=shutdown.target + - After=time-set.target time-sync.target IF OnCalendar= is used +''' + +scope_unit_opts = [ + 'OOMPolicy', + 'RuntimeMaxSec', + 'RuntimeRandomizedExtraSec' +] +''' + This is a list of options that are unit.scope specific. This will be used to parse the unit files. + Scope units manage a set of externally created system processes, and unlike service units, they can't fork. + Scope unit files may include a [Unit] section, as well as a [Scope] section that enables the use of exec, + kill, and resource-control options, as well as the listed scope specific options. + + Implicit dependencies: + - None + + Default dependencies: + - Before= and Conflicts=shutdown.target +''' + +kill_unit_opts = [ + 'KillMode', + 'KillSignal', + 'RestartKillSignal', + 'SendSIGHUP', + 'SendSIGKILL', + 'FinalKillSignal', + 'WatchdogSignal' +] +'''This is a list of options that are labelled as systemd.kill options, and are +made available to multiple types of units. See systemd.kill man page for more info.''' + +res_con_unit_opts = [ + 'CPUAccounting', + 'CPUWeight', + 'StartupCPUWeight', + 'CPUQuota', + 'CPUQuotaPeriodSec', + 'AllowedCPUs', + 'StartupAllowedCPUs', + 'AllowedMemoryNodes', + 'StartupAllowedMemoryNodes', + 'MemoryAccounting', + 'MemoryMin', + 'MemoryLow', + 'MemoryHigh', + 'MemoryMax', + 'MemorySwapMax', + 'MemoryZSwapMax', + 'TasksAccounting', + 'TasksMax', + 'IOAccounting', + 'IOWeight', + 'StartupIOWeight', + 'IODeviceWeight', + 'IOReadBandwidthMax', + 'IOWriteBandwidthMax', + 'IOReadIOPSMax', + 'IOWriteIOPSMax', + 'IODeviceLatencyTargetSec', + 'IPAccounting', + 'IPAddressAllow', + 'IPAddressDeny', + 'IPIngressFilterPath', + 'IPEgressFilterPath', + 'BPFProgram', + 'SocketBindAllow', + 'SocketBindDeny', + 'RestrictNetworkInterfaces', + 'DeviceAllow', + 'DevicePolicy', + 'Slice', + 'Delegate', + 'DisableControllers', + 'ManagedOOMSwap', + 'ManagedOOMMemoryPressure', + 'ManagedOOMMemoryPressureLimit', + 'ManagedOOMPreference' +] +'''This is a list of options that are labelled as systemd.resource-control options, and +are available to multiple types of units. See systemd.resource-control man page for more info.''' + +exec_unit_opts = [ + 'ExecSearchPath', + 'WorkingDirectory', + 'RootDirectory', + 'RootImage', + 'RootImageOptions', + 'RootHash', + 'RootHashSignature', + 'RootVerity', + 'MountAPIVFS', + 'ProtectProc', + 'ProcSubset', + 'BindPaths', + 'BindReadOnlyPaths', + 'MountImages', + 'MountFlags', + 'ExtensionImages', + 'ExtensionDirectories', + 'User', + 'Group', + 'DynamicUser', + 'SupplementaryGroups', + 'PAMName', + 'CapabilityBoundingSet', + 'Capabilities', + 'AmbientCapabilities', + 'NoNewPrivileges', + 'SecureBits', + 'SELinuxContext', + 'AppArmorProfile', + 'SmackProcessLabel', + 'LimitCPU', + 'LimitFSIZE', + 'LimitDATA', + 'LimitSTACK', + 'LimitCORE', + 'LimitRSS', + 'LimitNOFILE', + 'LimitAS', + 'LimitNPROC', + 'LimitMEMLOCK', + 'LimitLOCKS', + 'LimitSIGPENDING', + 'LimitMSGQUEUE', + 'LimitNICE', + 'LimitRTPRIO', + 'LimitRTTIME', + 'UMask', + 'CoredumpFilter', + 'KeyringMode', + 'OOMScoreAdjust', + 'TimerSlackNSec', + 'Personality', + 'IgnoreSIGPIPE', + 'Nice', + 'CPUSchedulingPolicy', + 'CPUSchedulingPriority', + 'CPUSchedulingResetOnFork', + 'CPUAffinity', + 'NUMAPolicy', + 'NUMAMask', + 'IOSchedulingClass', + 'IOSchedulingPriority', + 'ProtectSystem', + 'ProtectHome', + 'RuntimeDirectory', + 'StateDirectory', + 'CacheDirectory', + 'LogsDirectory', + 'ConfigurationDirectory', + 'RuntimeDirectoryMode', + 'StateDirectoryMode', + 'CacheDirectoryMode', + 'LogsDirectoryMode', + 'ConfigurationDirectoryMode', + 'RuntimeDirectoryPreserve', + 'TimeoutCleanSec', + 'ReadWritePaths', + 'ReadOnlyPaths', + 'ReadWriteDirectories', + 'ReadOnlyDirectories', + 'InaccessibleDirectories', + 'InaccessiblePaths', + 'ExecPaths', + 'NoExecPaths', + 'TemporaryFileSystem', + 'PrivateTmp', + 'PrivateDevices', + 'PrivateNetwork', + 'NetworkNamespacePath', + 'PrivateIPC', + 'IPCNamespacePath', + 'PrivateUsers', + 'ProtectHostname', + 'ProtectClock', + 'ProtectKernelTunables', + 'ProtectKernelModules', + 'ProtectKernelLogs', + 'ProtectControlGroups', + 'RestrictAddressFamilies', + 'RestrictFileSystems', + 'RestrictNamespaces', + 'LockPersonality', + 'MemoryDenyWriteExecute', + 'RestrictRealtime', + 'RestrictSUIDSGID', + 'RemoveIPC', + 'PrivateMounts', + 'PrivateMounts', + 'SystemCallFilter', + 'SystemCallErrorNumber', + 'SystemCallArchitectures', + 'SystemCallLog', + 'Environment', + 'EnvironmentFile', + 'PassEnvironment', + 'UnsetEnvironment', + 'StandardInput', + 'StandardOutput', + 'StandardError', + 'StandardInputText', + 'StandardInputData', + 'LogLevelMax', + 'LogExtraFields', + 'LogRateLimitIntervalSec', + 'LogRateLimitBurst', + 'LogFilterPatterns', + 'LogNamespace', + 'SyslogIdentifier', + 'SyslogFacility', + 'SyslogLevel', + 'SyslogLevelPrefix', + 'TTYPath', + 'TTYReset', + 'TTYVHangup', + 'TTYRows', + 'TTYColumns', + 'TTYVTDisallocate', + 'LoadCredential', + 'LoadCredentialEncrypted', + 'SetCredential', + 'SetCredentialEncrypted', + 'UtmpIdentifier', + 'UtmpMode' +] +'''This is a list of options that are labelled as systemd.exec options, and are +available to multiple types of units. See systemd.exec man page for more info.''' + +possible_unit_opts = { + 'target' : [unit_generic_opts, + unit_cond_assert_opts, + unit_install_opts], + + 'device' : [unit_generic_opts, + unit_cond_assert_opts, + unit_install_opts], + + 'service' : [unit_generic_opts, + unit_cond_assert_opts, + unit_install_opts, + serv_unit_opts, + exec_unit_opts, + res_con_unit_opts, + kill_unit_opts], + + 'slice' : [unit_generic_opts, + unit_cond_assert_opts, + unit_install_opts, + res_con_unit_opts], + + 'socket' : [unit_generic_opts, + unit_cond_assert_opts, + unit_install_opts, + sock_unit_opts, + kill_unit_opts, + res_con_unit_opts, + exec_unit_opts], + + 'mount' : [unit_generic_opts, + unit_cond_assert_opts, + unit_install_opts, + mnt_unit_opts, + kill_unit_opts, + res_con_unit_opts, + exec_unit_opts], + + 'automount' : [unit_generic_opts, + unit_cond_assert_opts, + unit_install_opts, + automnt_unit_opts], + + 'swap' : [unit_generic_opts, + unit_cond_assert_opts, + unit_install_opts, + swap_unit_opts, + kill_unit_opts, + res_con_unit_opts, + exec_unit_opts], + + 'path' : [unit_generic_opts, + unit_cond_assert_opts, + unit_install_opts, + path_unit_opts], + + 'timer' : [unit_generic_opts, + unit_cond_assert_opts, + unit_install_opts, + timer_unit_opts], + + 'scope' : [unit_generic_opts, + unit_cond_assert_opts, + scope_unit_opts, + kill_unit_opts, + res_con_unit_opts, + exec_unit_opts], + + 'conf' : [unit_generic_opts, + unit_cond_assert_opts, + unit_install_opts, + serv_unit_opts, + res_con_unit_opts, + exec_unit_opts] +} +''' + This is a mapping struct that maps a unit type to all of the possible lists of options above that should be + available to it. This will make the main code a lot cleaner and easier to read. The idea is to get a unit file + suffix, and iterate through possible_unit_opts[unit_type]. +''' + +unit_dependency_opts = [ + 'Wants', + 'Requires', + 'Requisite', + 'BindsTo', + 'PartOf', + 'Upholds', + 'OnSuccess', + 'Sockets', + 'Service', + 'Unit' +] +''' + This is a list of unit options that create dependencies to build the dependency and runtime tree. + This will be used to check the unit files after they are parsed and, if necessary, place those dependencies in the + unrecorded_dependencies list, right before the current unit file's info is sent to the dependency map dictionary. +''' + +space_delim_opts = [ + 'Documentation', + 'Before', + 'After', + 'Wants', + 'WantedBy', + 'Requires', + 'RequiredBy', + 'Requisite', + 'BindsTo', + 'PartOf', + 'Upholds', + 'Conflicts', + 'OnFailure', + 'OnSuccess', + 'PropagatesReloadTo', + 'ReloadPropagatedFrom', + 'PropagatesStopTo', + 'StopPropagatedFrom', + 'JoinsNamespaceOf', + 'RequiresMountsFor', + 'Sockets' +] +''' + List of all space-delimited options that ensures that all unit dependencies are recorded, and + options like Exec and Description aren't a list of strings when they need to be a single string. +''' + +command_directives = [ + 'ExecStart', + 'ExecCondition', + 'ExecStartPre', + 'ExecStartPost', + 'ExecReload', + 'ExecStop', + 'ExecStopPost' +] +''' + These command directives are options that list binaries that are used when the service contaning + these options is triggered. These are used by the master struct to map the binary specified to + libraries it requires, files it uses or references, and other interesting strings found in the binary. +''' + +ms_only_keys = [ + 'remote_path', + 'libraries', + 'files', + 'strings' +] +''' + This is a list of keys that have different formatting than the unit file entries and shouldn't be + parsed by the dependency map. +''' \ No newline at end of file diff --git a/sysd_obj_parser_tests.py b/sysd_obj_parser_tests.py new file mode 100644 index 0000000..9787b0d --- /dev/null +++ b/sysd_obj_parser_tests.py @@ -0,0 +1,684 @@ +''' +sysd_obj_parser_tests.py +Author: Michael R. Huettel +Author: Jason M. Carter +Date: December 2023 +Version: 1.0 + +Licensed under the Apache License, Version 2.0 (the "License") +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +Contributors: + Oak Ridge National Laboratory + +Description: This is a test file that is used to test lib/sysd_obj_parser.py. + Tests were broken into test suites based on the class being tested, and tests + are performed from the bottom up for each test suite. When adding new unit + tests be sure to add them to the corresponding test suite function as well. +''' + +import unittest +import logging + +from os import mkdir, rmdir, remove, symlink, unlink, getcwd +from typing import List, Dict, Union + +from lib.sysd_obj_parser import SystemdFileFactory, DepDir, SymLink, UnitFile + + + +class TestSymLinks(unittest.TestCase): + + + def test_get_target_path_with_relative_path(self) -> None: + '''Verify relative paths are translated to absolute paths correctly when parsing sym links''' + + test_unit = SymLink(f'{getcwd()}/', 'test/', 'relative_sym_link.target') + + self.assertEqual(test_unit.target_path, 'test/target_dir/') + self.assertEqual(test_unit.target_unit, 'sym_link_target.target') + + sym_link_struct: Dict[str, Dict[str, Union[str, List]]] = test_unit.record() + + self.assertEqual(sym_link_struct, { + 'metadata': { + 'file_type': 'sym_link', + 'sym_link_path': 'test/', + 'sym_link_unit': 'relative_sym_link.target', + 'sym_link_target_path': 'test/target_dir/', + 'sym_link_target_unit': 'sym_link_target.target', + 'dependencies': ['sym_link_target.target'] + } + }) + + + def test_get_target_path_with_same_parent(self) -> None: + '''Verify relative paths within the same dir as the target are translated to absolute paths correctly when parsing sym links''' + + test_unit = SymLink(f'{getcwd()}/', 'test/target_dir/', 'same_parent_sym_link.target') + + self.assertEqual(test_unit.target_path, 'test/target_dir/') + self.assertEqual(test_unit.target_unit, 'sym_link_target.target') + + sym_link_struct: Dict[str, Dict[str, Union[str, List]]] = test_unit.record() + + self.assertEqual(sym_link_struct, { + 'metadata': { + 'file_type': 'sym_link', + 'sym_link_path': 'test/target_dir/', + 'sym_link_unit': 'same_parent_sym_link.target', + 'sym_link_target_path': 'test/target_dir/', + 'sym_link_target_unit': 'sym_link_target.target', + 'dependencies': ['sym_link_target.target'] + } + }) + + def test_get_target_path_with_relative_dot_path(self) -> None: + '''Verify relative paths with ../ notation are translated to absolute paths correctly when parsing sym links''' + + test_unit = SymLink(f'{getcwd()}/', 'test/relative_sym_link_dir/', 'relative_dot_sym_link.target') + + self.assertEqual(test_unit.target_path, 'test/target_dir/') + self.assertEqual(test_unit.target_unit, 'sym_link_target.target') + + sym_link_struct: Dict[str, Dict[str, Union[str, List]]] = test_unit.record() + + self.assertEqual(sym_link_struct, { + 'metadata': { + 'file_type': 'sym_link', + 'sym_link_path': 'test/relative_sym_link_dir/', + 'sym_link_unit': 'relative_dot_sym_link.target', + 'sym_link_target_path': 'test/target_dir/', + 'sym_link_target_unit': 'sym_link_target.target', + 'dependencies': ['sym_link_target.target'] + } + }) + + + def test_get_target_path_with_abs_path(self) -> None: + '''Verify absolute paths are copied over correctly when parsing sym links''' + + test_unit = SymLink(f'{getcwd()}/', 'test/abs_sym_link_dir/', 'abs_sym_link.target') + + self.assertEqual(test_unit.target_path, 'test/target_dir/') + self.assertEqual(test_unit.target_unit, 'sym_link_target.target') + + sym_link_struct: Dict[str, Dict[str, Union[str, List]]] = test_unit.record() + + self.assertEqual(sym_link_struct, { + 'metadata': { + 'file_type': 'sym_link', + 'sym_link_path': 'test/abs_sym_link_dir/', + 'sym_link_unit': 'abs_sym_link.target', + 'sym_link_target_path': 'test/target_dir/', + 'sym_link_target_unit': 'sym_link_target.target', + 'dependencies': ['sym_link_target.target'] + } + }) + + + def test_sym_link_parsing_failure(self) -> None: + '''Verify logging message is displayed when file is being parsed as a sym link when it isn't one.''' + + test_unit = SymLink(f'{getcwd()}/', 'test/', 'not_a_sym_link.target') + + with self.assertLogs('root', level='WARNING') as logs: + logging.getLogger('root').warning('"test/not_a_sym_link.target" is not a sym link but is being parsed as one.') + self.assertEqual(logs.output, ['WARNING:root:"test/not_a_sym_link.target" is not a sym link but is being parsed as one.']) + + + def test_sym_link_record(self) -> None: + '''Verify all sym link info is being recorded and returned correctly. See above for path parsing validation''' + + test_unit = SymLink(f'{getcwd()}/', 'test/', 'relative_sym_link.target') + + sym_link_struct: Dict[str, Dict[str, Union[str, List]]] = test_unit.record() + + self.assertEqual(sym_link_struct, { + 'metadata': { + 'file_type': 'sym_link', + 'sym_link_path': 'test/', + 'sym_link_unit': 'relative_sym_link.target', + 'sym_link_target_path': 'test/target_dir/', + 'sym_link_target_unit': 'sym_link_target.target', + 'dependencies': ['sym_link_target.target'] + } + }) + + with self.assertLogs('root', level='DEBUG') as logs: + logging.getLogger('root').debug("Symbolic link structure created:\n{'file_type': 'sym_link', 'sym_link_path': 'test/', 'sym_link_unit': 'test_unit.target', 'sym_link_target_path': 'test/sym_link_dir/', 'sym_link_target_unit': 'unit.target', 'dependencies': ['unit.target']}") + self.assertEqual(logs.output, ["DEBUG:root:Symbolic link structure created:\n{'file_type': 'sym_link', 'sym_link_path': 'test/', 'sym_link_unit': 'test_unit.target', 'sym_link_target_path': 'test/sym_link_dir/', 'sym_link_target_unit': 'unit.target', 'dependencies': ['unit.target']}"]) + + + +class TestDependencyDirectories(unittest.TestCase): + + def test_update_config_files(self) -> None: + '''Verify that config files are parsed correctly''' + + test_dep_dir = DepDir() + dir_items = ['config1', 'config2', 'config3'] + test_dep_dir.update_config_files(dir_items) + + self.assertTrue(test_dep_dir.config_files == ['config1', 'config2', 'config3']) + self.assertTrue(test_dep_dir.wants_deps == []) + self.assertTrue(test_dep_dir.requires_deps == []) + self.assertTrue(test_dep_dir.all_deps == []) + + more_dir_items = ['config4', 'config5', 'config6'] + test_dep_dir.update_config_files(more_dir_items) + + self.assertTrue(test_dep_dir.config_files == ['config1', 'config2', 'config3', 'config4', 'config5', 'config6']) + self.assertTrue(test_dep_dir.wants_deps == []) + self.assertTrue(test_dep_dir.requires_deps == []) + self.assertTrue(test_dep_dir.all_deps == []) + + + def test_update_wants_deps(self) -> None: + '''Verify that wants directories are parsed correctly''' + + test_dep_dir = DepDir() + dir_items = ['unit1.target', 'unit2.target'] + test_dep_dir.update_wants_deps(dir_items) + + self.assertTrue(test_dep_dir.wants_deps == ['unit1.target', 'unit2.target']) + self.assertTrue(test_dep_dir.all_deps == ['unit1.target', 'unit2.target']) + self.assertTrue(test_dep_dir.config_files == []) + self.assertTrue(test_dep_dir.requires_deps == []) + + more_dir_items = ['unit3.target', 'unit4.target'] + test_dep_dir.update_wants_deps(more_dir_items) + + self.assertTrue(test_dep_dir.wants_deps == ['unit1.target', 'unit2.target', 'unit3.target', 'unit4.target']) + self.assertTrue(test_dep_dir.all_deps == ['unit1.target', 'unit2.target', 'unit3.target', 'unit4.target']) + self.assertTrue(test_dep_dir.config_files == []) + self.assertTrue(test_dep_dir.requires_deps == []) + + def test_update_requires_deps(self) -> None: + '''Verify that requires directories are parsed correctly''' + + test_dep_dir = DepDir() + dir_items = ['unit1.target', 'unit2.target'] + test_dep_dir.update_requires_deps(dir_items) + + self.assertTrue(test_dep_dir.requires_deps == ['unit1.target', 'unit2.target']) + self.assertTrue(test_dep_dir.all_deps == ['unit1.target', 'unit2.target']) + self.assertTrue(test_dep_dir.config_files == []) + self.assertTrue(test_dep_dir.wants_deps == []) + + more_dir_items = ['unit3.target', 'unit4.target'] + test_dep_dir.update_requires_deps(more_dir_items) + + self.assertTrue(test_dep_dir.requires_deps == ['unit1.target', 'unit2.target', 'unit3.target', 'unit4.target']) + self.assertTrue(test_dep_dir.all_deps == ['unit1.target', 'unit2.target', 'unit3.target', 'unit4.target']) + self.assertTrue(test_dep_dir.config_files == []) + self.assertTrue(test_dep_dir.wants_deps == []) + + def test_update_all_deps(self) -> None: + '''Verify that wants and requires directories are both adding units to the all_deps list''' + + test_dep_dir = DepDir() + dir_items = ['unit1.target', 'unit2.target'] + test_dep_dir.update_wants_deps(dir_items) + + self.assertTrue(test_dep_dir.wants_deps == ['unit1.target', 'unit2.target']) + self.assertTrue(test_dep_dir.all_deps == ['unit1.target', 'unit2.target']) + self.assertTrue(test_dep_dir.config_files == []) + self.assertTrue(test_dep_dir.requires_deps == []) + + more_dir_items = ['unit3.target', 'unit4.target'] + test_dep_dir.update_requires_deps(more_dir_items) + + self.assertTrue(test_dep_dir.wants_deps == ['unit1.target', 'unit2.target']) + self.assertTrue(test_dep_dir.requires_deps == ['unit3.target', 'unit4.target']) + self.assertTrue(test_dep_dir.all_deps == ['unit1.target', 'unit2.target', 'unit3.target', 'unit4.target']) + self.assertTrue(test_dep_dir.config_files == []) + + def test_update_dep_dir(self) -> None: + '''Verify dep_dir_paths is updated correctly''' + + test_dep_dir = DepDir() + + test_dep_dir.update_dep_dir('/etc/systemd', '/system/', 'unit.target.wants') + self.assertEqual(test_dep_dir.dep_dir_paths, ['/system/unit.target.wants']) + + test_dep_dir.update_dep_dir('/etc/systemd', '/system/', 'unit.target.requires') + self.assertEqual(test_dep_dir.dep_dir_paths, ['/system/unit.target.wants', '/system/unit.target.requires']) + + def test_check_dep_dir(self) -> None: + '''Finishing testing parsing update functions by verifying invalid dep dir types are caught''' + + test_dep_dir = DepDir() + test_dep_dir.check_dep_dir('/etc/systemd', '/system/', 'unit.service.invalid') + + with self.assertLogs('root', level='WARNING') as logs: + logging.getLogger('root').warning('Unknown or invalid folder type: "invalid" for /etc/systemd/system/unit.service.invalid') + self.assertTrue(logs.output, 'WARNING:root:Unknown or invalid folder type: "invalid" for /etc/systemd/system/unit.service.invalid') + + test_dep_dir.check_dep_dir('/etc/systemd', '/system/', 'unit.target.wants') + test_dep_dir.check_dep_dir('/etc/systemd', '/system/', 'unit.target.requires') + test_dep_dir.check_dep_dir('/etc/systemd', '/system/', 'config.files.d') + + + def test_dep_dir_record(self): + '''Verify record function records all entries that aren't empty and returns them. See above for parsing verification''' + + test_dep_dir = DepDir() + wants_items = ['unit1.target', 'unit2.target'] + config_items = ['config1', 'config2'] + requires_items = ['unit3.target', 'unit4.target'] + + test_dep_dir.update_wants_deps(wants_items) + test_dep_dir.update_requires_deps(requires_items) + test_dep_dir.update_config_files(config_items) + dep_dir_struct: Dict[str, Union[str, List]] = test_dep_dir.record() + + self.assertIn('file_type', dep_dir_struct['metadata']) + self.assertIn('dependency_folder_paths', dep_dir_struct['metadata']) + self.assertIn('dependencies', dep_dir_struct['metadata']) + self.assertIn('Wants', dep_dir_struct['metadata']) + self.assertIn('Requires', dep_dir_struct['metadata']) + self.assertNotIn('Config', dep_dir_struct['metadata']) + + with self.assertLogs('root', level='DEBUG') as init_logs: + logging.getLogger('root').debug("Initial dependency directory structure created:\n{'file_type': 'dep_dir', 'dependency_folder_paths': ['/system/unit.service.wants'], 'dependencies': ['unit1.target', 'unit2.target']}") + self.assertTrue(init_logs.output, ["DEBUG:root:Initial dependency directory structure created:\n{'file_type': 'dep_dir', 'dependency_folder_paths': ['/system/unit.service.wants'], 'dependencies': ['unit1.target', 'unit2.target']}"]) + + + +class TestUnitFiles(unittest.TestCase): + + def test_get_unit_type(self) -> None: + '''Verify that error message pops up when invalid unit type are presented''' + test_unit = UnitFile('/system/path/', 'unit.invalid') + + with self.assertLogs('root', level='WARNING') as logs: + logging.getLogger('root').warning('"/system/path/unit.invalid" is an invalid or unknown unit file type, returning target type instead') + self.assertEqual(logs.output, ['WARNING:root:"/system/path/unit.invalid" is an invalid or unknown unit file type, returning target type instead']) + self.assertEqual(test_unit.unit_type, 'target') + + test_unit = UnitFile('/system/path/', 'unit.target') + self.assertEqual(test_unit.unit_type, 'target') + test_unit = UnitFile('/system/path/', 'unit.device') + self.assertEqual(test_unit.unit_type, 'device') + test_unit = UnitFile('/system/path/', 'unit.service') + self.assertEqual(test_unit.unit_type, 'service') + test_unit = UnitFile('/system/path/', 'unit.slice') + self.assertEqual(test_unit.unit_type, 'slice') + test_unit = UnitFile('/system/path/', 'unit.socket') + self.assertEqual(test_unit.unit_type, 'socket') + test_unit = UnitFile('/system/path/', 'unit.mount') + self.assertEqual(test_unit.unit_type, 'mount') + test_unit = UnitFile('/system/path/', 'unit.automount') + self.assertEqual(test_unit.unit_type, 'automount') + test_unit = UnitFile('/system/path/', 'unit.swap') + self.assertEqual(test_unit.unit_type, 'swap') + test_unit = UnitFile('/system/path/', 'unit.path') + self.assertEqual(test_unit.unit_type, 'path') + test_unit = UnitFile('/system/path/', 'unit.timer') + self.assertEqual(test_unit.unit_type, 'timer') + test_unit = UnitFile('/system/path/', 'unit.scope') + self.assertEqual(test_unit.unit_type, 'scope') + test_unit = UnitFile('/system/path/', 'unit.conf') + self.assertEqual(test_unit.unit_type, 'conf') + + + def test_check_implicit_dependencies(self) -> None: + '''Verify implicit dependencies are being added correctly''' + + test_unit = UnitFile('/system/path', 'unit.socket') + test_unit.unit_struct = { + 'metadata' : { 'file_type': 'unit_file' }, + 'Service': 'other_unit.service' + } + test_unit.check_implicit_dependencies(test_unit.unit_type) + + self.assertEqual(test_unit.unit_struct, {'metadata': {'file_type': 'unit_file'}, 'Service': 'other_unit.service'}) + + test_unit = UnitFile('/system/path', 'unit.socket') + test_unit.unit_struct = { + 'metadata' : { 'file_type': 'unit_file' } + } + test_unit.check_implicit_dependencies(test_unit.unit_type) + + self.assertEqual(test_unit.unit_struct, {'metadata': { 'file_type': 'unit_file', 'iSocket_of': ['unit.service']}}) + + test_unit = UnitFile('/system/path', 'unit.timer') + test_unit.unit_struct = { + 'metadata' : { 'file_type': 'unit_file' }, + 'Unit': 'other_unit.service' + } + test_unit.check_implicit_dependencies(test_unit.unit_type) + + self.assertEqual(test_unit.unit_struct, {'metadata': {'file_type': 'unit_file'}, 'Unit': 'other_unit.service'}) + + test_unit = UnitFile('/system/path', 'unit.timer') + test_unit.unit_struct = { + 'metadata' : { 'file_type': 'unit_file' } + } + test_unit.check_implicit_dependencies(test_unit.unit_type) + + self.assertEqual(test_unit.unit_struct, {'metadata': { 'file_type': 'unit_file', 'iTimer_for': ['unit.service']}}) + + test_unit = UnitFile('/system/path', 'unit.target') + test_unit.unit_struct = { + 'metadata' : { 'file_type': 'unit_file' } + } + test_unit.check_implicit_dependencies(test_unit.unit_type) + + self.assertEqual(test_unit.unit_struct, {'metadata': {'file_type': 'unit_file'}}) + + + def test_check_option(self) -> None: + '''Verify option is check for validity and raise a warning if an invalid option is detected''' + test_unit = UnitFile('/system/path', 'unit.target') + + option = test_unit.check_option('Wants') + self.assertTrue(option == 'Wants') + + option = test_unit.check_option('Invalid') + with self.assertLogs('root', level='WARNING') as logs: + logging.getLogger('root').warning('"Invalid" is not a valid option for target units. Please investigate "Invalid" option in unit.target') + self.assertEqual(logs.output, ['WARNING:root:"Invalid" is not a valid option for target units. Please investigate "Invalid" option in unit.target']) + + + def test_format_arguments(self) -> None: + '''Verify aruguments are parsed correctly when encountered''' + + test_unit = UnitFile('/system/path', 'unit.target') + + test_unit.arguments = test_unit.format_arguments('Wants', 'unit1.target unit2.target unit3.target') + self.assertEqual(test_unit.arguments, ['unit1.target', 'unit2.target', 'unit3.target']) + + test_unit.arguments = test_unit.format_arguments('Requires', 'unit1.target unit2.target') + self.assertEqual(test_unit.arguments, ['unit1.target', 'unit2.target']) + + test_unit.arguments = test_unit.format_arguments('ExecStart', '/usr/bin/bin -f options -t now') + self.assertEqual(test_unit.arguments, ['/usr/bin/bin -f options -t now']) + + + def test_update_unit_file(self) -> None: + '''Verify unit files are found and sent to be parsed correctly. See above for parsing verification''' + + test_unit = UnitFile('/system/', 'unit.service') + test_unit.update_unit_file('test/', 'lib/', 'unit.service') + + self.assertEqual(test_unit.unit_struct, { + 'metadata': {'file_type': 'unit_file'}, + 'Wants': ['unit1.target', 'unit2.target'], + 'ExecStart': ['/usr/bin/bin --some-args', '/usr/bin/another.bin'], + 'ExecStartPost': ['/usr/bin/bin --another-arg --multi-line-arg'] + }) + + + def test_unit_file_record(self): + '''Verify unit file is recorded and returned''' + + test_unit = UnitFile('/system/', 'unit.target') + test_unit.unit_struct = { + 'metadata': {'file_type': 'unit_file'}, + 'Wants': ['unit1.target', 'unit2.target'], + 'ExecStart': ['/usr/bin/bin --some-args'] + } + + unit_struct = test_unit.record() + with self.assertLogs('root', level='DEBUG') as logs: + logging.getLogger('root').debug("Final unit file structure being returned: {'metadata': {'file_type': 'unit_file'}, 'Wants': ['unit1.target', 'unit2.target'], 'ExecStart': ['/usr/bin/bin --some-args']}") + + self.assertEqual(unit_struct, { + 'metadata': {'file_type': 'unit_file'}, + 'Wants': ['unit1.target', 'unit2.target'], + 'ExecStart': ['/usr/bin/bin --some-args'] + }) + self.assertEqual(logs.output, ["DEBUG:root:Final unit file structure being returned: {'metadata': {'file_type': 'unit_file'}, 'Wants': ['unit1.target', 'unit2.target'], 'ExecStart': ['/usr/bin/bin --some-args']}"]) + + + +class TestSystemdFileFactory(unittest.TestCase): + + def test_parse_dep_dir(self) -> None: + test_unit = SystemdFileFactory(f'{getcwd()}/') + test_unit.parse_file('test/', 'unit.target.wants') + + self.assertIsNotNone(test_unit.dep_dir) + + + def test_parse_sym_link(self) -> None: + test_unit = SystemdFileFactory(f'{getcwd()}/') + test_struct = test_unit.parse_file('test/', 'sym_link.target') + + self.assertIsNotNone(test_unit.sym_link) + + self.assertEqual(test_unit.sym_link.target_path, 'test/') + self.assertEqual(test_unit.sym_link.target_unit, 'unit.target') + + self.assertEqual(test_struct, { + 'metadata': { + 'file_type': 'sym_link', + 'sym_link_path': 'test/', + 'sym_link_unit': 'sym_link.target', + 'sym_link_target_path': 'test/', + 'sym_link_target_unit': 'unit.target', + 'dependencies': ['unit.target'] + } + }) + + + def test_parse_dupe_sym_link(self) -> None: + test_unit = SystemdFileFactory(f'{getcwd()}/') + test_unit.parse_file('test/', 'sym_link.target') + test_unit.parse_file('test/unit.target.wants/', 'sym_link.target') + + with self.assertLogs('root', level='WARNING') as logs: + logging.getLogger('root').warning(f'''Systemd Mapper is trying to record multiple sym links for one unit file. + Investigate "dupe_link.target" to see if there is a sym link chain for sysd files or a logic flaw in the program.''') + self.assertEqual(logs.output, ['''WARNING:root:Systemd Mapper is trying to record multiple sym links for one unit file. + Investigate "dupe_link.target" to see if there is a sym link chain for sysd files or a logic flaw in the program.''']) + + + def test_parse_unit_file(self) -> None: + test_unit = SystemdFileFactory(f'{getcwd()}/') + test_unit.parse_file('test/', 'unit.target') + + self.assertIsNotNone(test_unit.unit_file) + + + def test_dedupe_remote_path(self) -> None: + test_unit = SystemdFileFactory(f'{getcwd()}/') + test_unit.parse_file(f'{getcwd()}/test/', 'sym_link.target') + + self.assertEqual(test_unit.unit_path, 'test/') + + + def test_parse_failure(self) -> None: + test_unit = SystemdFileFactory(f'{getcwd()}/') + test_unit.parse_file('test/', 'not_a_file') + + with self.assertLogs('root', level='WARNING') as logs: + logging.getLogger('root').warning(f'Error determining which systemd file type "not_a_file" is') + self.assertEqual(logs.output, ['WARNING:root:Error determining which systemd file type "not_a_file" is']) + + +def get_sym_link_tests() -> unittest.TestSuite: + '''Create a test suite to test all symlink functions''' + + sym_link_test_suite = unittest.TestSuite() + sym_link_test_suite.addTest(TestSymLinks('test_get_target_path_with_relative_path')) + sym_link_test_suite.addTest(TestSymLinks('test_get_target_path_with_same_parent')) + sym_link_test_suite.addTest(TestSymLinks('test_get_target_path_with_relative_dot_path')) + sym_link_test_suite.addTest(TestSymLinks('test_get_target_path_with_abs_path')) + sym_link_test_suite.addTest(TestSymLinks('test_sym_link_parsing_failure')) + sym_link_test_suite.addTest(TestSymLinks('test_sym_link_record')) + + return sym_link_test_suite + + +def get_dep_dir_tests() -> unittest.TestSuite: + '''Create a test suite to test all dependency directories''' + + dep_dir_test_suite = unittest.TestSuite() + dep_dir_test_suite.addTest(TestDependencyDirectories('test_update_config_files')) + dep_dir_test_suite.addTest(TestDependencyDirectories('test_update_wants_deps')) + dep_dir_test_suite.addTest(TestDependencyDirectories('test_update_requires_deps')) + dep_dir_test_suite.addTest(TestDependencyDirectories('test_update_all_deps')) + dep_dir_test_suite.addTest(TestDependencyDirectories('test_update_dep_dir')) + dep_dir_test_suite.addTest(TestDependencyDirectories('test_check_dep_dir')) + dep_dir_test_suite.addTest(TestDependencyDirectories('test_dep_dir_record')) + + return dep_dir_test_suite + + +def get_unit_file_tests() -> unittest.TestSuite: + '''Create a test suite to test all unit file parsing operations''' + + unit_file_test_suite = unittest.TestSuite() + unit_file_test_suite.addTest(TestUnitFiles('test_get_unit_type')) + unit_file_test_suite.addTest(TestUnitFiles('test_check_implicit_dependencies')) + unit_file_test_suite.addTest(TestUnitFiles('test_check_option')) + unit_file_test_suite.addTest(TestUnitFiles('test_format_arguments')) + unit_file_test_suite.addTest(TestUnitFiles('test_update_unit_file')) + unit_file_test_suite.addTest(TestUnitFiles('test_unit_file_record')) + + return unit_file_test_suite + + +def get_systemd_factory_tests() -> unittest.TestSuite: + '''Create a test suite for all of the factory operation tests''' + + factory_test_suite = unittest.TestSuite() + factory_test_suite.addTest(TestSystemdFileFactory('test_parse_dep_dir')) + factory_test_suite.addTest(TestSystemdFileFactory('test_parse_sym_link')) + factory_test_suite.addTest(TestSystemdFileFactory('test_parse_unit_file')) + factory_test_suite.addTest(TestSystemdFileFactory('test_parse_dupe_sym_link')) + factory_test_suite.addTest(TestSystemdFileFactory('test_dedupe_remote_path')) + factory_test_suite.addTest(TestSystemdFileFactory('test_parse_failure')) + + return factory_test_suite + + +def test_suite_setup(test_directories: List[str], sym_link_map: Dict[str, str], target_file: str) -> str: + '''Create necessary directories and symbolic links for testing''' + + status = 'SUCCESS' + + for directory in test_directories: + try: + mkdir(directory) + except FileExistsError as exception: + logging.warning(f'{exception}') + status = 'FAIL' + + with open(target_file, 'w') as out_file: + out_file.write('#This is a comment line and should not be recorded\n') + out_file.write('This line should also not be recorded\n') + out_file.write('[Unit]\n') + out_file.write('Wants=unit1.target unit2.target\n') + out_file.write('[Service]\n') + out_file.write('ExecStart=/usr/bin/bin --some-args\n') + out_file.write('ExecStart=/usr/bin/another.bin\n') + out_file.write('ExecStartPost=/usr/bin/bin --another-arg \\\n') + out_file.write(' --multi-line-arg') + + for target, source in sym_link_map.items(): + try: + symlink(target, source) + except FileExistsError as exception: + logging.warning(f'{exception}') + status = 'FAIL' + + return status + + +def test_suite_cleanup(test_directories: List[str], sym_link_map: Dict[str, str], target_file: str) -> bool: + '''Remove artifacts that were created for symbolic link testing''' + + status = 'SUCCESS' + + for sym_link in sym_link_map.values(): + try: + unlink(sym_link) + except FileNotFoundError as exception: + logging.warning(f'{exception}') + status = 'FAIL' + + try: + remove(target_file) + except FileNotFoundError as exception: + logging.warning(f'{exception}') + status = 'FAIL' + + for directory in test_directories[::-1]: + try: + rmdir(directory) + except OSError as exception: + logging.warning(f'{exception}') + status = 'FAIL' + + return status + + +def main() -> None: + runner = unittest.TextTestRunner() + + sym_link_test_dirs = [ 'test', 'test/target_dir', 'test/relative_sym_link_dir', 'test/abs_sym_link_dir' ] + sym_link_map = { + 'target_dir/sym_link_target.target': 'test/relative_sym_link.target', + 'sym_link_target.target': 'test/target_dir/same_parent_sym_link.target', + '../target_dir/sym_link_target.target': 'test/relative_sym_link_dir/relative_dot_sym_link.target', + f'{getcwd()}/test/target_dir/sym_link_target.target': 'test/abs_sym_link_dir/abs_sym_link.target' + } + sym_link_tgt_file = 'test/target_dir/sym_link_target.target' + + unit_file_test_dirs = [ 'test', 'test/lib' ] + unit_file_sym_link_map = {} + unit_file_tgt_file = 'test/lib/unit.service' + + factory_dirs = [ 'test', 'test/unit.target.wants', 'test/target_dir' ] + factory_sym_link_map = { + 'unit.target': 'test/sym_link.target', + '../unit.target': 'test/unit.target.wants/sym_link.target' + } + factory_tgt_file = 'test/unit.target' + + print('\nSetting up sym link artifacts') + status = test_suite_setup(sym_link_test_dirs, sym_link_map, sym_link_tgt_file) + print(f'Sym link artifact creation: {status}') + print('\nTesting sym link parsing and recording functions...') + runner.run(get_sym_link_tests()) + print('Cleaning up sym link artifacts') + status = test_suite_cleanup(sym_link_test_dirs, sym_link_map, sym_link_tgt_file) + print(f'Sym link artifact cleanup: {status}') + + print('\nTesting dependency directory parsing and recording functions...') + runner.run(get_dep_dir_tests()) + print('No artifacts to clean up') + + print('\nSetting up unit file artifacts') + status = test_suite_setup(unit_file_test_dirs, unit_file_sym_link_map, unit_file_tgt_file) + print(f'Unit file artifact creation: {status}') + print('\nTesting unit file parsing and recording functions...') + runner.run(get_unit_file_tests()) + print('Cleaning up unit file artifacts') + status = test_suite_cleanup(unit_file_test_dirs, unit_file_sym_link_map, unit_file_tgt_file) + print(f'Unit file artifact cleanup: {status}') + + print('\nSetting up factory artifacts') + status = test_suite_setup(factory_dirs, factory_sym_link_map, factory_tgt_file) + print(f'Factory artifact creation: {status}') + print('\nTesting unit file factory functionality...') + runner.run(get_systemd_factory_tests()) + print('Cleaning up factory artifacts') + status = test_suite_cleanup(factory_dirs, factory_sym_link_map, factory_tgt_file) + print(f'Factory artifact cleanup: {status}') + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/systemd_snapshot.py b/systemd_snapshot.py new file mode 100755 index 0000000..ae1d0a4 --- /dev/null +++ b/systemd_snapshot.py @@ -0,0 +1,272 @@ +#!/usr/bin/python3 +''' +Systemd Snapshot +Authors: Mike Huettel, Jason M. Carter +Date: December 2023 +Version: 1.0 + +Licensed under the Apache License, Version 2.0 (the "License") +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +Contributors: + Oak Ridge National Laboratory + +Description: This tool creates a systemd snapshot of either locally or remotely hosted file systems, and + records collected data for forensic analysis. It does this by parsing all of the unit files contained + within the default systemd unit paths (system only) and records all unit file data, implicit dependencies, + and explicit dependencies. Systemd Snapshot will then record all of this information in a master struct + (ms) file, and create both an interactive graph file for use with cytoscape and a json-formatted + dependency map. + + The dependency map begins with one unit file (default.target by default) and maps out all dependencies + that are created by that unit. After dependencies are recorded the dependency repeats this dependency + mapping for each dependency that is created, until no more dependencies remain. Both forward and + backward dependency relationships are maintained within the dependency map, but not all unit files in + the master structure will be recorded here since startup processes vary based on runlevel. Currently, + only default.target is supported as the initial unit file without changing this in the systemd_mapping + file, but in the future the -t option will be able to be used from the cmd line to start the dependency + map with a different unit file. This will allow users to start at an arbitrary point within the startup + process and view dependencies, conditions, etc. that are required after a specific point during startup, + or allow users to see what will start up when a different runlevel is specified. + + Optionally, you can create networkx graphs from the information in the master structure that is built from + the Systemd filesystem and files. These graphs are DIRECTED MULTIGRAPHS: each edge has a source and a + target AND there can be multiple edges between two vertices. The multiple edges are distinct by their labels. + + Vertices in the graph represent the following information: UNIT files, ALIAS symbolic links, COMMANDs executed + via Systemd, EXECUTABLES within COMMANDS, LIBRARYs used by EXECUTABLES, and useful STRINGS within EXECUTABLES. + The edges in the graph established NAMED relationships between these Systemd elements. + + A Tree can be created from the complete graph by designating a root node from any UNIT FILE within the + systemd structure. This is actually VERY handy for focusing your analysis efforts. + + Graphs can also be trasferred to Cytoscape for visualization via Cytoscape's REST api and the py4cytoscape + library. There is a capability to build a custom style for your graph -- that will require some additional + documentation. +''' + +import logging + +from argparse import ArgumentParser, RawDescriptionHelpFormatter +from os import getcwd +from pathlib import Path + +from lib.systemd_mapping import map_systemd_full, map_dependencies +from lib.file_handlers import create_output_file, load_input_file + +# JMC: for graphing capability in Cytoscape. +from lib.grapher import Grapher +from lib.style import Style + + +def init_logger( name: str, level: str ) -> logging: + """Establish a logger for the system to use with a given name and log level. + This also uses a log message formatter that can be customized as needed. + """ + + def logForLevel(self, message, *args, **kwargs): + if self.isEnabledFor(5): + self._log(5, message, args, **kwargs) + + def logToRoot(message, *args, **kwargs): + logging.log(5, message, *args, **kwargs) + + logging.addLevelName(5, 'VDEBUG') + setattr(logging, 'VDEBUG', 5) + setattr(logging.getLoggerClass(), 'vdebug', logForLevel) + setattr(logging, 'vdebug', logToRoot) + + logger = logging.getLogger( name ) + logger.setLevel( level.upper() ) + + custom_handler = logging.StreamHandler() + formatter = logging.Formatter( + fmt='%(asctime)s.%(msecs)02d %(levelname)s [%(funcName)s]: %(message)s', + datefmt='%H:%M:%S' + ) + custom_handler.setFormatter( formatter ) + logger.addHandler( custom_handler ) + + return logger + + +def main(): + '''This tool creates a systemd snapshot of either locally or remotely hosted file systems, and records + collected data for forensic analysis.''' + + parser = ArgumentParser( + prog='systemd_snapshot', + formatter_class=RawDescriptionHelpFormatter, + description='This tool creates a systemd snapshot of either locally or remotely hosted file systems, and records collected data for forensic analysis.', + epilog=''' +Example usage: + +systemd_snapshot.py -a master - create a systemd snapshot of the local system, saved to working-dir/snapshot_ms.json +systemd_snapshot.py -a deps - a master struct is required for dep map and graph. Since no path is specified, + snapshot_ms.json will be created first, and then a dep map will be made from it +systemd_snapshot.py -a deps -p ss_ms.json -t runlevel2.target - use ss_ms.json file to create a dep map originating from runlevel2.target +systemd_snapshot.py -a master -o new/snapshot - save snapshot to new/snapshot_ms.json +systemd_snapshot.py -a deps -p /path/to/snapshot_ms.json - use specified master struct snapshot to build only the dep map +systemd_snapshot.py -a graph -p /path/to/snapshot_ms.json - use specified master struct snapshot to build only the graph +systemd_snapshot.py -a all -p /remotely/hosted/fs/root - create ms snapshot from remote fs and build dep map and graph from it +systemd_snapshot.py -a all -p /remote/fs -o new/snaps - create ms snapshot from remote fs, build dep map and graph from master struct, + and save all files as new/snaps_*''') + parser.add_argument( + '-a', + '--action', + type=str, + dest='action', + default='all', + help='''Action to take. One of {'master', 'deps', 'graph', 'all'}. If action is 'master' or 'all', path option will be used as a pointer to a remotely + hosted fs. Otherwise, the path option will be used as a pointer to a master struct snapshot file.''') + + parser.add_argument( + '-o', + '--output-file', + type=str, + dest='output_file', + default=f'{getcwd()}/data/snapshot', + help='File path to save master struct as. All artifacts will be created in the same directory. (Default: working-directory/snapshot)') + + parser.add_argument( + '-f', + '--force-overwrite', + dest='overwrite', + action='store_true', + help='Allow systemd snapshot to overwrite files if they already exist.') + + parser.add_argument( + '-p', + '--path', + type=Path, + dest='user_path', + default='/', + help='''Use -p followed by a path to set the root path to a remotely hosted filesystem if used with master or all actions, + or the path to a ms.json file if used with other actions. Defaults to the locally hosted filesystem.''') + + parser.add_argument( + '-t', + '--target-unit', + type=str, + metavar='TARGET_UNIT', + dest='origin_unit', + default='default.target', + help='Use -t followed by a unit name to start the dependency map from the specified unit file instead of default.target') + + parser.add_argument( + '-l', + '--log-level', + type=str, + dest='log_level', + default='INFO', + help='Change logging level if desired. Options from most to least verbose: [ vDEBUG, DEBUG, INFO, WARNING, ERROR, CRITICAL ]') + + parser.add_argument( + '-D', + '--depth', + type=int, + dest='depth', + default=0, + help='the depth of the tree to produce.') + + parser.add_argument( + "-S", + "--style-file", + type=str, + default='data/graph_style.json', + dest='style_file', + help='''The style to use for the graph; if a path to a file it will be used as a json style specification; + otherwise, it is assumed to be a current cytoscape style.''') + + args = parser.parse_args() + output_file = args.output_file + + log = init_logger(__name__, args.log_level) + action = args.action.lower() + origin_unit = args.origin_unit.lower() + + if args.user_path == Path('/'): + user_path = '' + else: + user_path = str( args.user_path.absolute() ) + + log.debug( f'path given: {user_path}' ) + log.debug( f'action: {args.action}' ) + log.debug( f'log level: {args.log_level}' ) + log.debug( f'graph style: {args.style_file}' ) + + log.info( f'overwrite output files: {args.overwrite}') + + if action in ('master', 'all'): + # if master or all is the chosen action, user_path will be passed as the remote_path + master_struct = map_systemd_full(user_path, log) + create_output_file(master_struct, 'ms', output_file, args.overwrite, log) + + elif action not in ('master', 'all') and user_path == '': + # if no path to a ms file is given, no remote_path is used which parses the locally hosted system + log.info( f'No path given. Parsing systemd to build a master struct' ) + master_struct = map_systemd_full(user_path, log) + create_output_file(master_struct, 'ms', output_file, args.overwrite, log) + + else: + # if a path is given w/other than master or all, load the file at the given path for future actions + master_struct = load_input_file(user_path, log) + + if action in ('dep', 'deps', 'all'): + dependency_map = map_dependencies(master_struct, origin_unit, log) + create_output_file(dependency_map, 'dm', output_file, args.overwrite, log) + + if action in ('graph', 'all'): + # the user wants a graph created and transmitted via REST to Cytoscape. + # this requires the master_struct map. + # optional arguments requires are: args.root and args.depth and args.style_file + + grapher = Grapher( 'systemd_graph', log ) + + # build the ENTIRE graph; if we want a tree subgraph, that will be constructed FROM THIS + # larger graph -- we will need all the attributes provided here. + log.info("Starting to build main graph tree...") + G = grapher.build( master_struct ) + log.info("Finished building main graph tree...") + + if origin_unit != 'default.target': + # build a TREE from the larger graph. + # TODO: Make this more general -- right now we can only specify UNIT type nodes. + log.info( f'Building subgraph starting with {origin_unit}...' ) + source = ( origin_unit, 'UNIT' ) + T = grapher.build_tree( G, source, args.depth ) + grapher.transmit_to_cytoscape( T ) + log.info('Finished building subgraph...') + + else: + # just transmit the entire graph. + grapher.transmit_to_cytoscape( G ) + + if args.style_file: + log.info('Applying style settings to graph...') + # the user has provided either a style file or a style name. + sf = Path( args.style_file ) + if sf.is_file(): + # a json style file has been provided. + style_json = Style.read_style_file( sf ) + + gstyle = Style('systemd_graph_style', log) + name = gstyle.create( style_json ) + log.debug("The style name used for the newly created style is: {}".format( name )) + gstyle.activate() + else: + # the user has provided a style name. + gstyle = Style( args.style_file, log ) + gstyle.activate() + +if __name__ == '__main__': + main()