Files

518 lines
20 KiB
Python
Executable File

#!/usr/local/autopkg/python
#
# Copyright 2010-2012 Per Olofsson
#
# 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.
import grp
import os
import plistlib
import pwd
import re
import shutil
import stat
import subprocess
import tempfile
from xml.parsers.expat import ExpatError
__all__ = ["Packager", "PackagerError"]
class PackagerError(Exception):
pass
class Packager:
"""Create an Apple installer package.
Must be run as root."""
re_pkgname = re.compile(r"^[a-z0-9][a-z0-9 ._\-]*$", re.I)
re_id = re.compile(r"^[a-z0-9]([a-z0-9 \-]*[a-z0-9])?$", re.I)
re_version = re.compile(r"^[a-z0-9_ ]*[0-9][a-z0-9_ -]*$", re.I)
def __init__(self, log, request, name, uid, gid):
"""Arguments:
log A logger instance.
request A request in plist format.
name Name of the component to package.
uid The UID of the user that made the request.
gid The GID of the user that made the request.
"""
self.log = log
self.request = request
self.name = name
self.uid = uid
self.gid = gid
self.tmproot = None
def package(self):
"""Main method."""
try:
self.verify_request()
self.copy_pkgroot()
self.apply_chown()
self.make_component_property_list()
return self.create_pkg()
finally:
self.cleanup()
def verify_request(self):
"""Verify that the request is valid."""
def verify_dir_and_owner(path, uid):
try:
info = os.lstat(path)
except OSError as e:
raise PackagerError(f"Can't stat {path}: {e}")
if info.st_uid != uid:
raise PackagerError(f"{path} isn't owned by {uid}")
if stat.S_ISLNK(info.st_mode):
raise PackagerError(f"{path} is a soft link")
if not stat.S_ISDIR(info.st_mode):
raise PackagerError(f"{path} is not a directory")
def cmd_output(cmd):
"""Outputs a stdout, stderr tuple from command output using a Popen"""
p = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=False
)
out, err = p.communicate()
if err:
self.log.debug(f"WARNING: errors from command '{', '.join(cmd)}':")
self.log.debug(err.decode())
return (out, err)
def get_mounts():
"""Returns a list of mounted volume paths as reported by diskutil."""
out, err = cmd_output(["/usr/sbin/diskutil", "list", "-plist"])
try:
du_list = plistlib.loads(out)
except ExpatError:
self.log.debug("WARNING: Error parsing diskutil output.")
self.log.debug(err)
return []
vols = set()
if "AllDisksAndPartitions" in du_list:
for disk in du_list["AllDisksAndPartitions"]:
if "MountPoint" in disk:
vols.add(disk["MountPoint"])
if "Partitions" in disk:
for part in disk["Partitions"]:
if "MountPoint" in part:
vols.add(part["MountPoint"])
else:
self.log.debug("Missing AllDisksAndPartitions key in diskutil output")
return list(vols)
def check_ownerships_enabled(path):
"""Return True if 'ignore ownerships' is not set on the volume on which
'path' resides, False if otherwise. We warn and return True on
unexpected behavior."""
# resolve the absolute path if a symlink
path = os.path.realpath(path)
# get mount points
mounts = get_mounts()
# move '/' to the end so we're sure to evaluate it before other
# mount points (/ would match any mountpoint)
if "/" in mounts:
mounts.remove("/")
mounts.append("/")
if mounts:
self.log.debug(f"Found mounted volumes: {', '.join(mounts)}")
else:
self.log.debug(
"WARNING: No mountpoints could be determined for "
"checking ownerships."
)
return True
# find the mountpoint that has our path
mount_for_path = None
for mount in mounts:
if path.startswith(mount):
mount_for_path = mount
break
if not mount_for_path:
self.log.debug(
f"WARNING: Checking disk ownerships for path '{path}' "
"failed. Attempting to continue.."
)
return True
# look for 'ignore ownerships' setting on the disk
# if 'GlobalPermissionsEnabled' is true, ownerships are _not_ ignored
self.log.debug(f"Checking disk ownerships for mount '{mount_for_path}'..")
out, err = cmd_output(
["/usr/sbin/diskutil", "info", "-plist", mount_for_path]
)
try:
du_info = plistlib.loads(out)
except ExpatError:
self.log.debug("WARNING: Error parsing diskutil output.")
self.log.debug(err)
return True
if "GlobalPermissionsEnabled" not in du_info:
self.log.debug(
"WARNING: Couldn't read 'ignore ownerships' "
f"setting for mount point '{mount_for_path}'. Attempting to "
"continue."
)
return True
if not du_info["GlobalPermissionsEnabled"]:
return False
else:
return True
self.log.debug("Verifying packaging request")
# Check that a disk-based pkgroot isn't somewhere where 'ignore ownerships'
# is set.
if not check_ownerships_enabled(self.request["pkgroot"]):
raise PackagerError(
(
"'Ignore ownerships' is set on the disk where pkgroot "
f"'{self.request['pkgroot']}' "
"was set, and packaging cannot continue. Ownerships must "
"be enabled on the volume where a package is to be built."
)
)
# Check owner and type of directories.
verify_dir_and_owner(self.request["pkgroot"], self.uid)
self.log.debug("pkgroot ok")
verify_dir_and_owner(self.request["pkgdir"], self.uid)
self.log.debug("pkgdir ok")
# Check name.
if len(self.request["pkgname"]) > 80:
raise PackagerError("Package name too long")
if not self.re_pkgname.search(self.request["pkgname"]):
raise PackagerError("Invalid package name")
if self.request["pkgname"].lower().endswith(".pkg"):
raise PackagerError("Package name mustn't include '.pkg'")
self.log.debug("pkgname ok")
# Check ID.
if len(self.request["id"]) > 80:
raise PackagerError("Package id too long")
components = self.request["id"].split(".")
if len(components) < 2:
raise PackagerError("Invalid package id")
for comp in components:
if not self.re_id.search(comp):
raise PackagerError("Invalid package id")
self.log.debug("id ok")
# Check version.
if len(self.request["version"]) > 40:
raise PackagerError("Version too long")
components = self.request["version"].split(".")
if len(components) < 1:
raise PackagerError(f"Invalid version \"{self.request['version']}\"")
for comp in components:
if not self.re_version.search(comp):
raise PackagerError(f'Invalid version component "{comp}"')
self.log.debug("version ok")
# Make sure infofile and resources exist and can be read.
if self.request["infofile"]:
try:
with open(self.request["infofile"], "rb"):
pass
except OSError as e:
raise PackagerError(f"Can't open infofile: {e}")
self.log.debug("infofile ok")
# Make sure scripts is a directory and its contents
# are executable.
if self.request["scripts"]:
if self.request["pkgtype"] == "bundle":
raise PackagerError(
"Installer scripts are not supported with bundle package types."
)
if not os.path.isdir(self.request["scripts"]):
raise PackagerError(
f"Can't find scripts directory: {self.request['scripts']}"
)
for script in ["preinstall", "postinstall"]:
script_path = os.path.join(self.request["scripts"], script)
if os.path.exists(script_path) and not os.access(script_path, os.X_OK):
raise PackagerError(
f"{script} script found in {self.request['scripts']} but it is "
"not executable!"
)
self.log.debug("scripts ok")
self.log.info("Packaging request verified")
def copy_pkgroot(self):
"""Copy pkgroot to temporary directory."""
self.log.debug("Copying package root")
self.tmproot = tempfile.mkdtemp()
self.tmp_pkgroot = os.path.join(self.tmproot, self.name)
os.mkdir(self.tmp_pkgroot)
os.chmod(self.tmp_pkgroot, 0o1775)
os.chown(self.tmp_pkgroot, 0, 80)
try:
p = subprocess.Popen(
("/usr/bin/ditto", self.request["pkgroot"], self.tmp_pkgroot),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
(_, err) = p.communicate()
except OSError as e:
raise PackagerError(
f"ditto execution failed with error code {e.errno}: {e.strerror}"
)
if p.returncode != 0:
raise PackagerError(
f"Couldn't copy pkgroot from {self.request['pkgroot']} to "
f"{self.tmp_pkgroot}: {' '.join(str(err).split())}"
)
self.log.info(f"Package root copied to {self.tmp_pkgroot}")
def apply_chown(self):
"""Change owner and group, and permissions if the 'mode' key was set."""
self.log.debug("Applying chown")
def verify_relative_valid_path(root, path):
if len(path) < 1:
raise PackagerError("Empty chown path")
checkpath = root
parts = path.split(os.sep)
for part in parts:
if part in (".", ".."):
raise PackagerError(". and .. is not allowed in chown path")
checkpath = os.path.join(checkpath, part)
relpath = checkpath[len(root) + 1 :]
if not os.path.exists(checkpath):
raise PackagerError(f"chown path {relpath} does not exist")
if os.path.islink(checkpath):
raise PackagerError(f"chown path {relpath} is a soft link")
for entry in self.request["chown"]:
self.log.debug("Let's check the path!")
self.log.debug(entry["path"])
# Check path.
verify_relative_valid_path(self.tmp_pkgroot, entry["path"])
# Check user.
if isinstance(entry["user"], str):
try:
uid = pwd.getpwnam(entry["user"]).pw_uid
except KeyError:
raise PackagerError(f"Unknown chown user {entry['user']}")
else:
uid = int(entry["user"])
if uid < 0:
raise PackagerError(f"Invalid uid {uid}")
# Check group.
if isinstance(entry["group"], str):
try:
gid = grp.getgrnam(entry["group"]).gr_gid
except KeyError:
raise PackagerError(f"Unknown chown group {entry['group']}")
else:
gid = int(entry["group"])
if gid < 0:
raise PackagerError(f"Invalid gid {gid}")
self.log.info(
f"Setting owner and group of {entry['path']} to {entry['user']}:"
f"{entry['group']}"
)
# If an absolute path is passed in entry["path"], os.path.join
# will not join it to the tmp_pkgroot. We need to strip out
# the leading / to make sure we only touch the pkgroot.
chownpath = os.path.join(self.tmp_pkgroot, entry["path"].lstrip("/"))
if "mode" in list(entry.keys()):
chmod_present = True
else:
chmod_present = False
if os.path.isfile(chownpath):
os.lchown(chownpath, uid, gid)
if chmod_present:
self.log.info(f"Setting mode of {entry['path']} to {entry['mode']}")
os.lchmod(chownpath, int(entry["mode"], 8))
else:
for dirpath, dirnames, filenames in os.walk(chownpath):
try:
os.lchown(dirpath, uid, gid)
except OSError as e:
raise PackagerError(f"Can't lchown {dirpath}: {e}")
for path_entry in dirnames + filenames:
path = os.path.join(dirpath, path_entry)
try:
os.lchown(path, uid, gid)
if chmod_present:
os.lchmod(path, int(entry["mode"], 8))
except OSError as e:
raise PackagerError(f"Can't lchown {path}: {e}")
self.log.info("Chown applied")
def random_string(self, length):
rand = os.urandom(int((length + 1) / 2))
randstr = "".join(["%02x" % ord(c) for c in str(rand)])
return randstr[:length]
def make_component_property_list(self):
"""Use pkgutil --analyze to build a component property list; then
turn off package relocation"""
self.component_plist = os.path.join(self.tmproot, "component.plist")
try:
p = subprocess.Popen(
(
"/usr/bin/pkgbuild",
"--analyze",
"--root",
self.tmp_pkgroot,
self.component_plist,
),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
(_, err) = p.communicate()
except OSError as e:
raise PackagerError(
f"pkgbuild execution failed with error code {e.errno}: {e.strerror}"
)
if p.returncode != 0:
raise PackagerError(
f"pkgbuild failed with exit code {p.returncode}: "
f"{' '.join(str(err).split())}"
)
try:
with open(self.component_plist, "rb") as f:
plist = plistlib.load(f)
except BaseException:
raise PackagerError(f"Couldn't read {self.component_plist}")
# plist is an array of dicts, iterate through
for bundle in plist:
if bundle.get("BundleIsRelocatable"):
bundle["BundleIsRelocatable"] = False
try:
with open(self.component_plist, "wb") as f:
plist = plistlib.dump(plist, f)
except BaseException:
raise PackagerError(f"Couldn't write {self.component_plist}")
def create_pkg(self):
self.log.info("Creating package")
if self.request["pkgtype"] != "flat":
raise PackagerError(f"Unsupported pkgtype {self.request['pkgtype']}")
pkgname = self.request["pkgname"] + ".pkg"
pkgpath = os.path.join(self.request["pkgdir"], pkgname)
# Remove existing pkg if it exists and is owned by uid.
if os.path.exists(pkgpath):
try:
self.log.info("Checking if package is owned by uid")
if os.lstat(pkgpath).st_uid != self.uid:
raise PackagerError(
f"Existing pkg {pkgpath} not owned by {self.uid}"
)
if os.path.islink(pkgpath) or os.path.isfile(pkgpath):
os.remove(pkgpath)
else:
shutil.rmtree(pkgpath)
except OSError as e:
raise PackagerError(
f"Can't remove existing pkg {pkgpath}: {e.strerror}"
)
self.log.info("Creating random name")
# Use a temporary name while building.
temppkgname = (
f"autopkgtmp-{self.random_string(16)}-{self.request['pkgname']}.pkg"
)
temppkgpath = os.path.join(self.request["pkgdir"], temppkgname)
self.log.info("Starting cmd try block")
# Wrap package building in try/finally to remove temporary package if
# it fails.
try:
# make a pkgbuild cmd
cmd = [
"/usr/bin/pkgbuild",
"--root",
self.tmp_pkgroot,
"--identifier",
self.request["id"],
"--version",
self.request["version"],
"--ownership",
"preserve",
"--component-plist",
self.component_plist,
]
if self.request["infofile"]:
cmd.extend(["--info", self.request["infofile"]])
if self.request["scripts"]:
cmd.extend(["--scripts", self.request["scripts"]])
cmd.append(temppkgpath)
# Execute pkgbuild.
self.log.info("Sending package build command")
try:
p = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
)
(_, err) = p.communicate()
except OSError as e:
raise PackagerError(
f"pkgbuild execution failed with error code {e.errno}: {e.strerror}"
)
if p.returncode != 0:
raise PackagerError(
f"pkgbuild failed with exit code {p.returncode}: "
f"{' '.join(str(err).split())}"
)
self.log.info("Changing name and owner")
# Change to final name and owner.
os.rename(temppkgpath, pkgpath)
os.chown(pkgpath, self.uid, self.gid)
self.log.info(f"Created package at {pkgpath}")
return pkgpath
finally:
# Remove temporary package.
try:
os.remove(temppkgpath)
except OSError as e:
if e.errno != 2:
self.log.warn(
f"Can't remove temporary package at {temppkgpath}: {e.strerror}"
)
def cleanup(self):
"""Clean up resources."""
if self.tmproot:
shutil.rmtree(self.tmproot)