diff -Nru click-0.1.7~precise1~bzr150/bin/click click-0.3.1~precise1~test2/bin/click --- click-0.1.7~precise1~bzr150/bin/click 2013-07-19 03:16:19.000000000 +0000 +++ click-0.3.1~precise1~test2/bin/click 2013-08-13 08:12:35.000000000 +0000 @@ -30,7 +30,26 @@ from click import commands +def fix_stdout(): + if sys.version >= "3": + # Force encoding to UTF-8 even in non-UTF-8 locales. + import io + sys.stdout = io.TextIOWrapper( + sys.stdout.detach(), encoding="UTF-8", line_buffering=True) + else: + # Avoid having to do .encode("UTF-8") everywhere. + import codecs + sys.stdout = codecs.EncodedFile(sys.stdout, "UTF-8") + + def null_decode(input, errors="strict"): + return input, len(input) + + sys.stdout.decode = null_decode + + def main(): + fix_stdout() + parser = OptionParser(dedent("""\ %%prog COMMAND [options] diff -Nru click-0.1.7~precise1~bzr150/click/build.py click-0.3.1~precise1~test2/click/build.py --- click-0.1.7~precise1~bzr150/click/build.py 2013-07-19 03:16:19.000000000 +0000 +++ click-0.3.1~precise1~test2/click/build.py 2013-08-13 08:12:35.000000000 +0000 @@ -26,6 +26,7 @@ import contextlib import hashlib +import io import json import os import re @@ -69,7 +70,7 @@ self.file_map[source_path] = dest_path def read_manifest(self, manifest_path): - with open(manifest_path) as manifest: + with io.open(manifest_path, encoding="UTF-8") as manifest: self.manifest = json.load(manifest) @property @@ -112,6 +113,27 @@ return None return tarinfo + def _pack(self, temp_dir, control_dir, data_dir, package_path): + data_tar_path = os.path.join(temp_dir, "data.tar.gz") + with contextlib.closing(FakerootTarFile.open( + name=data_tar_path, mode="w:gz", format=tarfile.GNU_FORMAT + )) as data_tar: + data_tar.add(data_dir, arcname="./", filter=self._filter_dot_click) + + control_tar_path = os.path.join(temp_dir, "control.tar.gz") + control_tar = tarfile.open( + name=control_tar_path, mode="w:gz", format=tarfile.GNU_FORMAT) + control_tar.add(control_dir, arcname="./") + control_tar.close() + + with ArFile(name=package_path, mode="w") as package: + package.add_magic() + package.add_data("debian-binary", b"2.0\n") + package.add_data( + "_click-binary", ("%s\n" % spec_version).encode("UTF-8")) + package.add_file("control.tar.gz", control_tar_path) + package.add_file("data.tar.gz", data_tar_path) + def build(self, dest_dir, manifest_path="manifest.json"): with make_temp_dir() as temp_dir: # Prepare data area. @@ -147,7 +169,7 @@ installed_size = match.group(1) control_path = os.path.join(control_dir, "control") osextras.ensuredir(os.path.dirname(control_path)) - with open(control_path, "w") as control: + with io.open(control_path, "w", encoding="UTF-8") as control: print(dedent("""\ Package: %s Version: %s @@ -177,27 +199,10 @@ preinst.write(static_preinst) # Pack everything up. - data_tar_path = os.path.join(temp_dir, "data.tar.gz") - with contextlib.closing(FakerootTarFile.open( - name=data_tar_path, mode="w:gz", format=tarfile.GNU_FORMAT - )) as data_tar: - data_tar.add( - root_path, arcname="./", filter=self._filter_dot_click) - - control_tar_path = os.path.join(temp_dir, "control.tar.gz") - control_tar = tarfile.open( - name=control_tar_path, mode="w:gz", format=tarfile.GNU_FORMAT) - control_tar.add(control_dir, arcname="./") - control_tar.close() - package_name = "%s_%s_%s.click" % ( self.name, self.epochless_version, self.architecture) package_path = os.path.join(dest_dir, package_name) - with ArFile(name=package_path, mode="w") as package: - package.add_magic() - package.add_data("debian-binary", b"2.0\n") - package.add_file("control.tar.gz", control_tar_path) - package.add_file("data.tar.gz", data_tar_path) + self._pack(temp_dir, control_dir, root_path, package_path) return package_path diff -Nru click-0.1.7~precise1~bzr150/click/commands/__init__.py click-0.3.1~precise1~test2/click/commands/__init__.py --- click-0.1.7~precise1~bzr150/click/commands/__init__.py 2013-07-19 03:16:19.000000000 +0000 +++ click-0.3.1~precise1~test2/click/commands/__init__.py 2013-08-13 08:12:35.000000000 +0000 @@ -22,15 +22,22 @@ "build", "buildsource", "contents", + "desktophook", "hook", "info", "install", "list", + "pkgdir", "register", "verify", ) +hidden_commands = ( + "desktophook", + ) + + def load_command(command): return importlib.import_module("click.commands.%s" % command) @@ -38,6 +45,8 @@ def help_text(): lines = [] for command in all_commands: + if command in hidden_commands: + continue mod = load_command(command) lines.append(" %-21s %s" % (command, mod.__doc__.splitlines()[0])) return "\n".join(lines) diff -Nru click-0.1.7~precise1~bzr150/click/commands/desktophook.py click-0.3.1~precise1~test2/click/commands/desktophook.py --- click-0.1.7~precise1~bzr150/click/commands/desktophook.py 1970-01-01 00:00:00.000000000 +0000 +++ click-0.3.1~precise1~test2/click/commands/desktophook.py 2013-08-13 08:12:35.000000000 +0000 @@ -0,0 +1,173 @@ +# Copyright (C) 2013 Canonical Ltd. +# Author: Colin Watson + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Click desktop hook. (Temporary; do not rely on this.)""" + +from __future__ import print_function + +import errno +import io +import json +from optparse import OptionParser +import os + +from click import osextras +from click.query import find_package_directory + + +COMMENT = \ + '# Generated by "click desktophook"; changes here will be overwritten.' + + +def desktop_entries(directory, only_ours=False): + for entry in osextras.listdir_force(directory): + if not entry.endswith(".desktop"): + continue + path = os.path.join(directory, entry) + if only_ours: + with io.open(path, encoding="UTF-8") as f: + if COMMENT not in f.read(): + continue + yield entry + + +def split_entry(entry): + entry = entry[:-8] # strip .desktop + return entry.split("_", 2) + + +def older(source_path, target_path): + """Return True iff source_path is older than target_path. + + It's also OK for target_path to be missing. + """ + try: + source_mtime = os.stat(source_path) + except OSError as e: + if e.errno == errno.ENOENT: + return False + try: + target_mtime = os.stat(target_path) + except OSError as e: + if e.errno == errno.ENOENT: + return True + return source_mtime < target_mtime + + +def read_hooks_for(path, package, app_name): + try: + directory = find_package_directory(path) + manifest_path = os.path.join( + directory, ".click", "info", "%s.manifest" % package) + with io.open(manifest_path, encoding="UTF-8") as manifest: + return json.load(manifest).get("hooks", {}).get(app_name, {}) + except Exception: + return {} + + +def quote_for_desktop_exec(s): + """Quote a string for Exec in a .desktop file. + + The rules are fairly awful. See: + http://standards.freedesktop.org/desktop-entry-spec/latest/ar01s06.html + """ + for c in s: + if c in " \t\n\"'\\><~|&;$*?#()`%": + break + else: + return s + quoted = [] + for c in s: + if c in "\"`$\\": + quoted.append("\\" + c) + elif c == "%": + quoted.append("%%") + else: + quoted.append(c) + escaped = [] + for c in "".join(quoted): + if c == "\\": + escaped.append("\\\\") + else: + escaped.append(c) + return '"%s"' % "".join(escaped) + + +# TODO: This is a very crude .desktop file mangler; we should instead +# implement proper (de)serialisation. +def write_desktop_file(target_path, source_path, profile): + osextras.ensuredir(os.path.dirname(target_path)) + with io.open(source_path, encoding="UTF-8") as source, \ + io.open(target_path, "w", encoding="UTF-8") as target: + source_dir = find_package_directory(source_path) + written_comment = False + seen_path = False + for line in source: + if not line.rstrip("\n") or line.startswith("#"): + # Comment + target.write(line) + elif line.startswith("["): + # Group header + target.write(line) + if not written_comment: + print(COMMENT, file=target) + elif "=" not in line: + # Who knows? + target.write(line) + else: + key, value = line.split("=", 1) + key = key.strip() + value = value.strip() + if key == "Exec": + target.write( + "%s=aa-exec -p %s -- %s\n" % + (key, quote_for_desktop_exec(profile), value)) + elif key == "Path": + target.write("%s=%s\n" % (key, source_dir)) + seen_path = True + elif key == "Icon": + icon_path = os.path.join(source_dir, value) + if os.path.exists(icon_path): + target.write("%s=%s\n" % (key, icon_path)) + else: + target.write("%s=%s\n" % (key, value)) + else: + target.write("%s=%s\n" % (key, value)) + if not seen_path: + target.write("Path=%s\n" % source_dir) + + +def run(argv): + parser = OptionParser("%prog desktophook [options]") + parser.parse_args(argv) + source_dir = os.path.expanduser("~/.local/share/click/hooks/desktop") + target_dir = os.path.expanduser("~/.local/share/applications") + source_entries = set(desktop_entries(source_dir)) + target_entries = set(desktop_entries(target_dir, only_ours=True)) + + for new_entry in source_entries: + package, app_name, version = split_entry(new_entry) + source_path = os.path.join(source_dir, new_entry) + target_path = os.path.join(target_dir, new_entry) + if older(source_path, target_path): + hooks = read_hooks_for(source_path, package, app_name) + if "apparmor" in hooks: + profile = "%s_%s_%s" % (package, app_name, version) + else: + profile = "unconfined" + write_desktop_file(target_path, source_path, profile) + + for remove_entry in target_entries - source_entries: + os.unlink(os.path.join(target_dir, remove_entry)) diff -Nru click-0.1.7~precise1~bzr150/click/commands/info.py click-0.3.1~precise1~test2/click/commands/info.py --- click-0.1.7~precise1~bzr150/click/commands/info.py 2013-07-19 03:16:19.000000000 +0000 +++ click-0.3.1~precise1~test2/click/commands/info.py 2013-08-13 08:12:35.000000000 +0000 @@ -35,6 +35,6 @@ "manifest", encoding="UTF-8") as manifest: manifest_json = json.loads(manifest.read()) print(json.dumps( - manifest_json, sort_keys=True, indent=4, + manifest_json, ensure_ascii=False, sort_keys=True, indent=4, separators=(",", ": "))) return 0 diff -Nru click-0.1.7~precise1~bzr150/click/commands/list.py click-0.3.1~precise1~test2/click/commands/list.py --- click-0.1.7~precise1~bzr150/click/commands/list.py 2013-07-19 03:16:19.000000000 +0000 +++ click-0.3.1~precise1~test2/click/commands/list.py 2013-08-13 08:12:35.000000000 +0000 @@ -17,6 +17,7 @@ from __future__ import print_function +import io import json from optparse import OptionParser import os @@ -59,14 +60,14 @@ parser.add_option( "--manifest", default=False, action="store_true", help="print JSON array of manifests of all installed packages") - options, _ = parser.parse_args() + options, _ = parser.parse_args(argv) json_output = [] for package, version, path in list_packages(options): if options.manifest: try: manifest_path = os.path.join( path, ".click", "info", "%s.manifest" % package) - with open(manifest_path) as manifest: + with io.open(manifest_path, encoding="UTF-8") as manifest: json_output.append(json.load(manifest)) except Exception: pass @@ -74,4 +75,5 @@ print("%s\t%s" % (package, version)) if options.manifest: print(json.dumps( - json_output, sort_keys=True, indent=4, separators=(",", ": "))) + json_output, ensure_ascii=False, sort_keys=True, indent=4, + separators=(",", ": "))) diff -Nru click-0.1.7~precise1~bzr150/click/commands/pkgdir.py click-0.3.1~precise1~test2/click/commands/pkgdir.py --- click-0.1.7~precise1~bzr150/click/commands/pkgdir.py 1970-01-01 00:00:00.000000000 +0000 +++ click-0.3.1~precise1~test2/click/commands/pkgdir.py 2013-08-13 08:12:35.000000000 +0000 @@ -0,0 +1,46 @@ +#! /usr/bin/python3 + +# Copyright (C) 2013 Canonical Ltd. + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Print the directory where a Click package is unpacked.""" + +from __future__ import print_function + +from optparse import OptionParser + +from click.paths import default_root +from click.query import find_package_directory +from click.user import ClickUser + + +def run(argv): + parser = OptionParser("%prog pkgdir {PACKAGE-NAME|PATH}") + parser.add_option( + "--root", metavar="PATH", default=default_root, + help="set top-level directory to PATH (default: %s)" % default_root) + parser.add_option( + "--user", metavar="USER", + help="look up PACKAGE-NAME for USER (if you have permission; " + "default: current user)") + options, args = parser.parse_args(argv) + if len(args) < 1: + parser.error("need package name") + if "/" in args[0]: + print(find_package_directory(args[0])) + else: + package_name = args[0] + registry = ClickUser(options.root, user=options.user) + print(registry.path(package_name)) + return 0 diff -Nru click-0.1.7~precise1~bzr150/click/hooks.py click-0.3.1~precise1~test2/click/hooks.py --- click-0.1.7~precise1~bzr150/click/hooks.py 2013-07-19 03:16:19.000000000 +0000 +++ click-0.3.1~precise1~test2/click/hooks.py 2013-08-13 08:12:35.000000000 +0000 @@ -23,17 +23,24 @@ __metaclass__ = type __all__ = [ "ClickHook", - "run_hooks", + "package_install_hooks", ] +from functools import partial +import grp +import io import json import os +import pwd +import re +from string import Formatter import subprocess from debian.deb822 import Deb822 from click import osextras from click.paths import hooks_dir +from click.user import ClickUser, ClickUsers def _read_manifest_hooks(root, package, version): @@ -42,13 +49,73 @@ manifest_path = os.path.join( root, package, version, ".click", "info", "%s.manifest" % package) try: - with open(manifest_path) as manifest: + with io.open(manifest_path, encoding="UTF-8") as manifest: return json.load(manifest).get("hooks", {}) except IOError: return {} +class ClickPatternFormatter(Formatter): + """A Formatter that handles simple $-expansions. + + `${key}` is replaced by the value of the `key` argument; `$$` is + replaced by `$`. Any `$` character not followed by `{...}` is preserved + intact. + """ + _expansion_re = re.compile(r"\${(.*?)}") + + def parse(self, format_string): + while True: + match = self._expansion_re.search(format_string) + if match is None: + if format_string: + yield format_string, None, None, None + return + start, end = match.span() + yield format_string[:match.start()], match.group(1), "", None + format_string = format_string[match.end():] + + def get_field(self, field_name, args, kwargs): + value = kwargs.get(field_name) + if value is None: + value = "" + return value, field_name + + def possible_expansion(self, s, format_string, *args, **kwargs): + """Check if s is a possible expansion. + + Any (keyword) arguments have the effect of binding some keys to + fixed values; unspecified keys may take any value, and will bind + greedily to the longest possible string. + + If s is a possible expansion, then this method returns a (possibly + empty) dictionary mapping all the unspecified keys to their bound + values. Otherwise, it returns None. + """ + ret = {} + regex_pieces = [] + group_names = [] + for literal_text, field_name, format_spec, conversion in \ + self.parse(format_string): + if literal_text: + regex_pieces.append(re.escape(literal_text)) + if field_name is not None: + if field_name in kwargs: + regex_pieces.append(re.escape(kwargs[field_name])) + else: + regex_pieces.append("(.*)") + group_names.append(field_name) + match = re.match("^%s$" % "".join(regex_pieces), s) + if match is None: + return None + for group in range(len(group_names)): + ret[group_names[group]] = match.group(group + 1) + return ret + + class ClickHook(Deb822): + _formatter = ClickPatternFormatter() + def __init__(self, name, sequence=None, fields=None, encoding="utf-8"): super(ClickHook, self).__init__( sequence=sequence, fields=fields, encoding=encoding) @@ -62,59 +129,203 @@ except IOError: raise KeyError("No click hook '%s' installed" % name) - def _run_commands(self): + @classmethod + def open_all(cls, hook_name): + for entry in osextras.listdir_force(hooks_dir): + if not entry.endswith(".hook"): + continue + try: + with open(os.path.join(hooks_dir, entry)) as f: + hook = cls(entry[:-5], f) + if hook.hook_name == hook_name: + yield hook + except IOError: + pass + + @property + def user_level(self): + return self.get("user-level", "no") == "yes" + + @property + def single_version(self): + return self.user_level or self.get("single-version", "no") == "yes" + + @property + def hook_name(self): + return self.get("hook-name", self.name) + + def app_id(self, package, version, app_name): + # TODO: perhaps this check belongs further up the stack somewhere? + if "_" in app_name or "/" in app_name: + raise ValueError( + "Application name '%s' may not contain _ or / characters" % + app_name) + return "%s_%s_%s" % (package, app_name, version) + + def _user_home(self, user): + if user is None: + return None + # TODO: make robust against removed users + # TODO: caching + return pwd.getpwnam(user).pw_dir + + def pattern(self, package, version, app_name, user=None): + app_id = self.app_id(package, version, app_name) + return self._formatter.format( + self["pattern"], id=app_id, user=user, home=self._user_home(user)) + + def _drop_privileges(self, username): + if os.geteuid() != 0: + return + pw = pwd.getpwnam(username) + os.setgroups( + [g.gr_gid for g in grp.getgrall() if username in g.gr_mem]) + # Portability note: this assumes that we have [gs]etres[gu]id, which + # is true on Linux but not necessarily elsewhere. If you need to + # support something else, there are reasonably standard alternatives + # involving other similar calls; see e.g. gnulib/lib/idpriv-drop.c. + os.setresgid(pw.pw_gid, pw.pw_gid, pw.pw_gid) + os.setresuid(pw.pw_uid, pw.pw_uid, pw.pw_uid) + assert os.getresuid() == (pw.pw_uid, pw.pw_uid, pw.pw_uid) + assert os.getresgid() == (pw.pw_gid, pw.pw_gid, pw.pw_gid) + os.environ["HOME"] = pw.pw_dir + + def _run_commands_user(self, user=None): + if self.user_level: + return user + else: + return self["user"] + + def _run_commands(self, user=None): if "exec" in self: - subprocess.check_call(self["exec"], shell=True) + drop_privileges = partial( + self._drop_privileges, self._run_commands_user(user=user)) + subprocess.check_call( + self["exec"], preexec_fn=drop_privileges, shell=True) if self.get("trigger", "no") == "yes": raise NotImplementedError("'Trigger: yes' not yet implemented") - def install(self, root, package, version, relative_path): - osextras.symlink_force( - os.path.join(root, package, version, relative_path), - self["pattern"] % package) - self._run_commands() - - def remove(self, package): - osextras.unlink_force(self["pattern"] % package) - self._run_commands() + def install(self, root, package, version, app_name, relative_path, + user=None): + # Prepare paths. + if self.user_level: + user_db = ClickUser(root, user=user) + target = os.path.join(user_db.path(package), relative_path) + else: + target = os.path.join(root, package, version, relative_path) + assert user is None + link = self.pattern(package, version, app_name, user=user) + link_dir = os.path.dirname(link) + + # Remove previous versions if necessary. + # TODO: This only works if the app ID only appears, at most, in the + # last component of the pattern path. + if self.single_version: + for previous_entry in osextras.listdir_force(link_dir): + previous_exp = self._formatter.possible_expansion( + os.path.join(link_dir, previous_entry), + self["pattern"], user=user, home=self._user_home(user)) + if previous_exp is None or "id" not in previous_exp: + continue + previous_id = previous_exp["id"] + try: + previous_package, previous_app_name, _ = previous_id.split( + "_", 2) + except ValueError: + continue + if (previous_package == package and + previous_app_name == app_name): + osextras.unlink_force( + os.path.join(link_dir, previous_entry)) + + # Install new links and run commands. + if self.user_level: + with user_db._dropped_privileges(): + osextras.ensuredir(link_dir) + osextras.symlink_force(target, link) + else: + osextras.symlink_force(target, link) + self._run_commands(user=user) + + def remove(self, package, version, app_name, user=None): + osextras.unlink_force( + self.pattern(package, version, app_name, user=user)) + self._run_commands(user=user) def _all_packages(self, root): - for package in osextras.listdir_force(root): - current_path = os.path.join(root, package, "current") - if os.path.islink(current_path): - version = os.readlink(current_path) - if "/" not in version: - yield package, version + """Return an iterable of all unpacked packages. + + If running a user-level hook, this returns (package, version, user) + for the current version of each package registered for each user. - def _relevant_packages(self, root): - for package, version in self._all_packages(root): + If running a system-level hook, this returns (package, version, + None) for each version of each unpacked package. + """ + if self.user_level: + for user, user_db in ClickUsers(root).items(): + for package, version in user_db.items(): + yield package, version, user + else: + for package in osextras.listdir_force(root): + current_path = os.path.join(root, package, "current") + if os.path.islink(current_path): + version = os.readlink(current_path) + if "/" not in version: + yield package, version, None + + def _relevant_apps(self, root): + """Return an iterable of all applications relevant for this hook.""" + for package, version, user in self._all_packages(root): manifest = _read_manifest_hooks(root, package, version) - if self.name in manifest: - yield package, version, manifest[self.name] + for app_name, hooks in manifest.items(): + if self.hook_name in hooks: + yield ( + package, version, app_name, user, + hooks[self.hook_name]) def install_all(self, root): - for package, version, relative_path in self._relevant_packages(root): - self.install(root, package, version, relative_path) + for package, version, app_name, user, relative_path in ( + self._relevant_apps(root)): + self.install( + root, package, version, app_name, relative_path, user=user) def remove_all(self, root): - for package, version, relative_path in self._relevant_packages(root): - self.remove(package) + for package, version, app_name, user, _ in self._relevant_apps(root): + self.remove(package, version, app_name, user=user) -def run_hooks(root, package, old_version, new_version): +def _app_hooks(hooks): + items = set() + for app_name in hooks: + for hook_name in hooks[app_name]: + items.add((app_name, hook_name)) + return items + + +def package_install_hooks(root, package, old_version, new_version, user=None): + """Run hooks following installation of a Click package. + + If user is None, only run system-level hooks. If user is not None, only + run user-level hooks for that user. + """ old_manifest = _read_manifest_hooks(root, package, old_version) new_manifest = _read_manifest_hooks(root, package, new_version) - for name in sorted(set(old_manifest) - set(new_manifest)): - try: - hook = ClickHook.open(name) - except KeyError: - pass - hook.remove(package) - - for name, relative_path in sorted(new_manifest.items()): - try: - hook = ClickHook.open(name) - except KeyError: - pass - hook.install(root, package, new_version, relative_path) + # Remove any targets for single-version hooks that were in the old + # manifest but not the new one. + for app_name, hook_name in sorted( + _app_hooks(old_manifest) - _app_hooks(new_manifest)): + for hook in ClickHook.open_all(hook_name): + if hook.user_level != (user is not None): + continue + if hook.single_version: + hook.remove(package, old_version, app_name, user=user) + + for app_name, app_hooks in sorted(new_manifest.items()): + for hook_name, relative_path in sorted(app_hooks.items()): + for hook in ClickHook.open_all(hook_name): + if hook.user_level != (user is not None): + continue + hook.install( + root, package, new_version, app_name, relative_path, + user=user) diff -Nru click-0.1.7~precise1~bzr150/click/install.py click-0.3.1~precise1~test2/click/install.py --- click-0.1.7~precise1~bzr150/click/install.py 2013-07-19 03:16:19.000000000 +0000 +++ click-0.3.1~precise1~test2/click/install.py 2013-08-13 08:12:35.000000000 +0000 @@ -30,6 +30,7 @@ import os import pwd import subprocess +import sys from contextlib import closing @@ -37,16 +38,13 @@ from debian.debian_support import Version from click import osextras -from click.hooks import run_hooks +from click.hooks import package_install_hooks from click.paths import frameworks_dir, preload_path from click.preinst import static_preinst_matches from click.user import ClickUser from click.versions import spec_version -CLICK_VERSION = "0.1" - - try: _DebFile.close DebFile = _DebFile @@ -135,11 +133,23 @@ if "/" in package_name: raise ValueError( 'Invalid character "/" in "name" entry: %s' % package_name) + if "_" in package_name: + raise ValueError( + 'Invalid character "_" in "name" entry: %s' % package_name) try: package_version = manifest["version"] except KeyError: raise ValueError('No "version" entry in manifest') + # TODO: perhaps just do full version validation? + if "/" in package_version: + raise ValueError( + 'Invalid character "/" in "version" entry: %s' % + package_version) + if "_" in package_version: + raise ValueError( + 'Invalid character "_" in "version" entry: %s' % + package_version) try: framework = manifest["framework"] @@ -156,19 +166,8 @@ with closing(DebFile(filename=path)) as package: return self.audit_control(package.control) - def _check_write_permissions(self, path): - while True: - if os.path.exists(path): - break - path = os.path.dirname(path) - if path == "/": - break - if not os.access(path, os.W_OK): - raise ClickInstallerPermissionDenied( - "No permission to write to %s; try running as root" % path) - def _drop_privileges(self, username): - if os.getuid() != 0: + if os.geteuid() != 0: return pw = pwd.getpwnam(username) os.setgroups( @@ -182,6 +181,32 @@ assert os.getresuid() == (pw.pw_uid, pw.pw_uid, pw.pw_uid) assert os.getresgid() == (pw.pw_gid, pw.pw_gid, pw.pw_gid) + def _euid_access(self, username, path, mode): + """Like os.access, but for the effective UID.""" + # TODO: Dropping privileges and calling + # os.access(effective_ids=True) ought to work, but for some reason + # appears not to return False when it should. It seems that we need + # a subprocess to check this reliably. At least we don't have to + # exec anything. + pid = os.fork() + if pid == 0: # child + self._drop_privileges(username) + os._exit(0 if os.access(path, mode) else 1) + else: # parent + _, status = os.waitpid(pid, 0) + return status == 0 + + def _check_write_permissions(self, path): + while True: + if os.path.exists(path): + break + path = os.path.dirname(path) + if path == "/": + break + if not self._euid_access("clickpkg", path, os.W_OK): + raise ClickInstallerPermissionDenied( + "No permission to write to %s as clickpkg user" % path) + def _install_preexec(self, inst_dir): self._drop_privileges("clickpkg") @@ -206,7 +231,7 @@ root_click = os.path.join(self.root, ".click") if not os.path.exists(root_click): os.makedirs(root_click) - if os.getuid() == 0: + if os.geteuid() == 0: pw = pwd.getpwnam("clickpkg") os.chown(root_click, pw.pw_uid, pw.pw_gid) @@ -216,20 +241,27 @@ "--force-not-root", "--instdir", inst_dir, "--admindir", os.path.join(inst_dir, ".click"), - "--path-exclude", "/.click/*", + "--path-exclude", "*/.click/*", "--log", os.path.join(root_click, "log"), "--no-triggers", "--install", path, ] - env = dict(os.environ) - preloads = [self._preload_path()] - if "LD_PRELOAD" in env: - preloads.append(env["LD_PRELOAD"]) - env["LD_PRELOAD"] = " ".join(preloads) - env["CLICK_BASE_DIR"] = self.root - subprocess.check_call( - command, preexec_fn=partial(self._install_preexec, inst_dir), - env=env) + with open(path, "rb") as fd: + env = dict(os.environ) + preloads = [self._preload_path()] + if "LD_PRELOAD" in env: + preloads.append(env["LD_PRELOAD"]) + env["LD_PRELOAD"] = " ".join(preloads) + env["CLICK_BASE_DIR"] = self.root + env["CLICK_PACKAGE_PATH"] = path + env["CLICK_PACKAGE_FD"] = str(fd.fileno()) + env.pop("HOME", None) + kwargs = {} + if sys.version >= "3.2": + kwargs["pass_fds"] = (fd.fileno(),) + subprocess.check_call( + command, preexec_fn=partial(self._install_preexec, inst_dir), + env=env, **kwargs) current_path = os.path.join(package_dir, "current") @@ -239,11 +271,12 @@ old_version = None else: old_version = None - run_hooks(self.root, package_name, old_version, package_version) + package_install_hooks( + self.root, package_name, old_version, package_version) new_path = os.path.join(package_dir, "current.new") osextras.symlink_force(package_version, new_path) - if os.getuid() == 0: + if os.geteuid() == 0: # shutil.chown would be more convenient, but it doesn't support # follow_symlinks=False in Python 3.3. # http://bugs.python.org/issue18108 diff -Nru click-0.1.7~precise1~bzr150/click/query.py click-0.3.1~precise1~test2/click/query.py --- click-0.1.7~precise1~bzr150/click/query.py 1970-01-01 00:00:00.000000000 +0000 +++ click-0.3.1~precise1~test2/click/query.py 2013-08-13 08:12:35.000000000 +0000 @@ -0,0 +1,43 @@ +# Copyright (C) 2013 Canonical Ltd. +# Author: Colin Watson + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Query information about installed Click packages.""" + +from __future__ import print_function + +__metaclass__ = type +__all__ = [ + 'find_package_directory', + ] + +import os + + +def _walk_up(path): + while True: + yield path + newpath = os.path.dirname(path) + if newpath == path: + return + path = newpath + + +def find_package_directory(path): + for directory in _walk_up(os.path.realpath(path)): + if os.path.isdir(os.path.join(directory, ".click", "info")): + return directory + break + else: + raise Exception("No package directory found for %s" % path) diff -Nru click-0.1.7~precise1~bzr150/click/tests/helpers.py click-0.3.1~precise1~test2/click/tests/helpers.py --- click-0.1.7~precise1~bzr150/click/tests/helpers.py 2013-07-19 03:16:19.000000000 +0000 +++ click-0.3.1~precise1~test2/click/tests/helpers.py 2013-08-13 08:12:35.000000000 +0000 @@ -78,9 +78,25 @@ if not hasattr(mock, "call"): - # mock 0.7.2, the version in Ubuntu 12.04 LTS, lacks the mock.call - # helper. Since it's so convenient, monkey-patch a partial backport + # mock 0.7.2, the version in Ubuntu 12.04 LTS, lacks mock.ANY and + # mock.call. Since it's so convenient, monkey-patch a partial backport # (from Python 3.3 unittest.mock) into place here. + class _ANY(object): + "A helper object that compares equal to everything." + + def __eq__(self, other): + return True + + def __ne__(self, other): + return False + + def __repr__(self): + return '' + + + mock.ANY = _ANY() + + class _Call(tuple): """ A tuple for holding the results of a call to a mock, either in the form diff -Nru click-0.1.7~precise1~bzr150/click/tests/test_build.py click-0.3.1~precise1~test2/click/tests/test_build.py --- click-0.1.7~precise1~bzr150/click/tests/test_build.py 2013-07-19 03:16:19.000000000 +0000 +++ click-0.3.1~precise1~test2/click/tests/test_build.py 2013-08-13 08:12:35.000000000 +0000 @@ -132,7 +132,7 @@ for key, value in ( ("Package", "com.ubuntu.test"), ("Version", "1.0"), - ("Click-Version", "0.1"), + ("Click-Version", "0.3"), ("Architecture", "all"), ("Maintainer", "Foo Bar "), ("Description", "test title"), diff -Nru click-0.1.7~precise1~bzr150/click/tests/test_hooks.py click-0.3.1~precise1~test2/click/tests/test_hooks.py --- click-0.1.7~precise1~bzr150/click/tests/test_hooks.py 2013-07-19 03:16:19.000000000 +0000 +++ click-0.3.1~precise1~test2/click/tests/test_hooks.py 2013-08-13 08:12:35.000000000 +0000 @@ -19,7 +19,9 @@ __metaclass__ = type __all__ = [ - "TestClickHook", + "TestClickHookSystemLevel", + "TestClickHookUserLevel", + "TestClickPatternFormatter", ] @@ -29,7 +31,8 @@ from textwrap import dedent from click import hooks -from click.hooks import ClickHook, run_hooks +from click.hooks import ClickHook, ClickPatternFormatter, package_install_hooks +from click.user import ClickUser from click.tests.helpers import TestCase, mkfile, mock @@ -43,40 +46,108 @@ hooks.hooks_dir = old_dir -class TestClickHook(TestCase): +class TestClickPatternFormatter(TestCase): def setUp(self): - super(TestClickHook, self).setUp() + super(TestClickPatternFormatter, self).setUp() + self.formatter = ClickPatternFormatter() + + def test_expands_provided_keys(self): + self.assertEqual( + "foo.bar", self.formatter.format("foo.${key}", key="bar")) + self.assertEqual( + "foo.barbaz", + self.formatter.format( + "foo.${key1}${key2}", key1="bar", key2="baz")) + + def test_expands_missing_keys_to_empty_string(self): + self.assertEqual("xy", self.formatter.format("x${key}y")) + + def test_preserves_unmatched_dollar(self): + self.assertEqual("$", self.formatter.format("$")) + self.assertEqual("$ {foo}", self.formatter.format("$ {foo}")) + self.assertEqual("x${y", self.formatter.format("${key}${y", key="x")) + + def test_possible_expansion(self): + self.assertEqual( + {"id": "abc"}, + self.formatter.possible_expansion( + "x_abc_1", "x_${id}_${num}", num="1")) + self.assertIsNone( + self.formatter.possible_expansion( + "x_abc_1", "x_${id}_${num}", num="2")) + + +class TestClickHookSystemLevel(TestCase): + def setUp(self): + super(TestClickHookSystemLevel, self).setUp() self.use_temp_dir() def test_open(self): with mkfile(os.path.join(self.temp_dir, "test.hook")) as f: print(dedent("""\ - Pattern: /usr/share/test/%s.test + Pattern: /usr/share/test/${id}.test # Comment Exec: test-update + User: root """), file=f) with temp_hooks_dir(self.temp_dir): hook = ClickHook.open("test") - self.assertCountEqual(["Pattern", "Exec"], hook.keys()) - self.assertEqual("/usr/share/test/%s.test", hook["pattern"]) + self.assertCountEqual(["Pattern", "Exec", "User"], hook.keys()) + self.assertEqual("/usr/share/test/${id}.test", hook["pattern"]) self.assertEqual("test-update", hook["exec"]) + self.assertFalse(hook.user_level) + + def test_hook_name_absent(self): + with mkfile(os.path.join(self.temp_dir, "test.hook")) as f: + print("Pattern: /usr/share/test/${id}.test", file=f) + with temp_hooks_dir(self.temp_dir): + hook = ClickHook.open("test") + self.assertEqual("test", hook.hook_name) + + def test_hook_name_present(self): + with mkfile(os.path.join(self.temp_dir, "test.hook")) as f: + print("Pattern: /usr/share/test/${id}.test", file=f) + print("Hook-Name: other", file=f) + with temp_hooks_dir(self.temp_dir): + hook = ClickHook.open("test") + self.assertEqual("other", hook.hook_name) + + def test_invalid_app_id(self): + with mkfile(os.path.join(self.temp_dir, "test.hook")) as f: + print(dedent("""\ + Pattern: /usr/share/test/${id}.test + # Comment + Exec: test-update + User: root + """), file=f) + with temp_hooks_dir(self.temp_dir): + hook = ClickHook.open("test") + self.assertRaises( + ValueError, hook.app_id, "package", "0.1", "app_name") + self.assertRaises( + ValueError, hook.app_id, "package", "0.1", "app/name") @mock.patch("subprocess.check_call") def test_run_commands(self, mock_check_call): with mkfile(os.path.join(self.temp_dir, "test.hook")) as f: print("Exec: test-update", file=f) + print("User: root", file=f) with temp_hooks_dir(self.temp_dir): hook = ClickHook.open("test") - hook._run_commands() - mock_check_call.assert_called_once_with("test-update", shell=True) + self.assertEqual("root", hook._run_commands_user(user=None)) + hook._run_commands(user=None) + mock_check_call.assert_called_once_with( + "test-update", preexec_fn=mock.ANY, shell=True) def test_install(self): with mkfile(os.path.join(self.temp_dir, "test.hook")) as f: - print("Pattern: %s/%%s.test" % self.temp_dir, file=f) + print("Pattern: %s/${id}.test" % self.temp_dir, file=f) with temp_hooks_dir(self.temp_dir): hook = ClickHook.open("test") - hook.install(self.temp_dir, "org.example.package", "1.0", "foo/bar") - symlink_path = os.path.join(self.temp_dir, "org.example.package.test") + hook.install( + self.temp_dir, "org.example.package", "1.0", "test-app", "foo/bar") + symlink_path = os.path.join( + self.temp_dir, "org.example.package_test-app_1.0.test") target_path = os.path.join( self.temp_dir, "org.example.package", "1.0", "foo", "bar") self.assertTrue(os.path.islink(symlink_path)) @@ -84,12 +155,14 @@ def test_upgrade(self): with mkfile(os.path.join(self.temp_dir, "test.hook")) as f: - print("Pattern: %s/%%s.test" % self.temp_dir, file=f) - symlink_path = os.path.join(self.temp_dir, "org.example.package.test") + print("Pattern: %s/${id}.test" % self.temp_dir, file=f) + symlink_path = os.path.join( + self.temp_dir, "org.example.package_test-app_1.0.test") os.symlink("old-target", symlink_path) with temp_hooks_dir(self.temp_dir): hook = ClickHook.open("test") - hook.install(self.temp_dir, "org.example.package", "1.0", "foo/bar") + hook.install( + self.temp_dir, "org.example.package", "1.0", "test-app", "foo/bar") target_path = os.path.join( self.temp_dir, "org.example.package", "1.0", "foo", "bar") self.assertTrue(os.path.islink(symlink_path)) @@ -97,36 +170,45 @@ def test_remove(self): with mkfile(os.path.join(self.temp_dir, "test.hook")) as f: - print("Pattern: %s/%%s.test" % self.temp_dir, file=f) - symlink_path = os.path.join(self.temp_dir, "org.example.package.test") + print("Pattern: %s/${id}.test" % self.temp_dir, file=f) + symlink_path = os.path.join( + self.temp_dir, "org.example.package_test-app_1.0.test") os.symlink("old-target", symlink_path) with temp_hooks_dir(self.temp_dir): hook = ClickHook.open("test") - hook.remove("org.example.package") + hook.remove("org.example.package", "1.0", "test-app") self.assertFalse(os.path.exists(symlink_path)) def test_install_all(self): with mkfile(os.path.join(self.temp_dir, "hooks", "new.hook")) as f: - print("Pattern: %s/%%s.new" % self.temp_dir, file=f) + print("Pattern: %s/${id}.new" % self.temp_dir, file=f) with mkfile(os.path.join( self.temp_dir, "test-1", "1.0", ".click", "info", "test-1.manifest")) as f: - f.write(json.dumps({"hooks": {"new": "target-1"}})) + f.write(json.dumps({ + "maintainer": + b"Unic\xc3\xb3de ".decode("UTF-8"), + "hooks": {"test1-app": {"new": "target-1"}}, + })) os.symlink("1.0", os.path.join(self.temp_dir, "test-1", "current")) with mkfile(os.path.join( self.temp_dir, "test-2", "2.0", ".click", "info", "test-2.manifest")) as f: - f.write(json.dumps({"hooks": {"new": "target-2"}})) + f.write(json.dumps({ + "maintainer": + b"Unic\xc3\xb3de ".decode("UTF-8"), + "hooks": {"test1-app": {"new": "target-2"}}, + })) os.symlink("2.0", os.path.join(self.temp_dir, "test-2", "current")) with temp_hooks_dir(os.path.join(self.temp_dir, "hooks")): hook = ClickHook.open("new") hook.install_all(self.temp_dir) - path_1 = os.path.join(self.temp_dir, "test-1.new") + path_1 = os.path.join(self.temp_dir, "test-1_test1-app_1.0.new") self.assertTrue(os.path.lexists(path_1)) self.assertEqual( os.path.join(self.temp_dir, "test-1", "1.0", "target-1"), os.readlink(path_1)) - path_2 = os.path.join(self.temp_dir, "test-2.new") + path_2 = os.path.join(self.temp_dir, "test-2_test1-app_2.0.new") self.assertTrue(os.path.lexists(path_2)) self.assertEqual( os.path.join(self.temp_dir, "test-2", "2.0", "target-2"), @@ -134,21 +216,21 @@ def test_remove_all(self): with mkfile(os.path.join(self.temp_dir, "hooks", "old.hook")) as f: - print("Pattern: %s/%%s.old" % self.temp_dir, file=f) + print("Pattern: %s/${id}.old" % self.temp_dir, file=f) with mkfile(os.path.join( self.temp_dir, "test-1", "1.0", ".click", "info", "test-1.manifest")) as f: - f.write(json.dumps({"hooks": {"old": "target-1"}})) + f.write(json.dumps({"hooks": {"test1-app": {"old": "target-1"}}})) os.symlink("1.0", os.path.join(self.temp_dir, "test-1", "current")) - path_1 = os.path.join(self.temp_dir, "test-1.old") + path_1 = os.path.join(self.temp_dir, "test-1_test1-app_1.0.old") os.symlink( os.path.join(self.temp_dir, "test-1", "1.0", "target-1"), path_1) with mkfile(os.path.join( self.temp_dir, "test-2", "2.0", ".click", "info", "test-2.manifest")) as f: - f.write(json.dumps({"hooks": {"old": "target-2"}})) + f.write(json.dumps({"hooks": {"test2-app": {"old": "target-2"}}})) os.symlink("2.0", os.path.join(self.temp_dir, "test-2", "current")) - path_2 = os.path.join(self.temp_dir, "test-2.old") + path_2 = os.path.join(self.temp_dir, "test-2_test2-app_2.0.old") os.symlink( os.path.join(self.temp_dir, "test-2", "2.0", "target-2"), path_2) with temp_hooks_dir(os.path.join(self.temp_dir, "hooks")): @@ -158,63 +240,356 @@ self.assertFalse(os.path.exists(path_2)) -class TestRunHooks(TestCase): +class TestClickHookUserLevel(TestCase): def setUp(self): - super(TestRunHooks, self).setUp() + super(TestClickHookUserLevel, self).setUp() self.use_temp_dir() - @mock.patch("click.hooks.ClickHook.open") - def test_removes_old_hooks(self, mock_open): + def test_open(self): + with mkfile(os.path.join(self.temp_dir, "test.hook")) as f: + print(dedent("""\ + User-Level: yes + Pattern: ${home}/.local/share/test/${id}.test + # Comment + Exec: test-update + """), file=f) + with temp_hooks_dir(self.temp_dir): + hook = ClickHook.open("test") + self.assertCountEqual(["User-Level", "Pattern", "Exec"], hook.keys()) + self.assertEqual( + "${home}/.local/share/test/${id}.test", hook["pattern"]) + self.assertEqual("test-update", hook["exec"]) + self.assertTrue(hook.user_level) + + def test_hook_name_absent(self): + with mkfile(os.path.join(self.temp_dir, "test.hook")) as f: + print("User-Level: yes", file=f) + print("Pattern: ${home}/.local/share/test/${id}.test", file=f) + with temp_hooks_dir(self.temp_dir): + hook = ClickHook.open("test") + self.assertEqual("test", hook.hook_name) + + def test_hook_name_present(self): + with mkfile(os.path.join(self.temp_dir, "test.hook")) as f: + print("User-Level: yes", file=f) + print("Pattern: ${home}/.local/share/test/${id}.test", file=f) + print("Hook-Name: other", file=f) + with temp_hooks_dir(self.temp_dir): + hook = ClickHook.open("test") + self.assertEqual("other", hook.hook_name) + + def test_invalid_app_id(self): + with mkfile(os.path.join(self.temp_dir, "test.hook")) as f: + print(dedent("""\ + User-Level: yes + Pattern: ${home}/.local/share/test/${id}.test + # Comment + Exec: test-update + """), file=f) + with temp_hooks_dir(self.temp_dir): + hook = ClickHook.open("test") + self.assertRaises( + ValueError, hook.app_id, "package", "0.1", "app_name") + self.assertRaises( + ValueError, hook.app_id, "package", "0.1", "app/name") + + @mock.patch("subprocess.check_call") + def test_run_commands(self, mock_check_call): + with mkfile(os.path.join(self.temp_dir, "test.hook")) as f: + print("User-Level: yes", file=f) + print("Exec: test-update", file=f) + with temp_hooks_dir(self.temp_dir): + hook = ClickHook.open("test") + self.assertEqual( + "test-user", hook._run_commands_user(user="test-user")) + hook._run_commands(user="test-user") + mock_check_call.assert_called_once_with( + "test-update", preexec_fn=mock.ANY, shell=True) + + @mock.patch("click.hooks.ClickHook._user_home") + def test_install(self, mock_user_home): + mock_user_home.return_value = "/home/test-user" + with mkfile(os.path.join(self.temp_dir, "test.hook")) as f: + print("User-Level: yes", file=f) + print("Pattern: %s/${id}.test" % self.temp_dir, file=f) + with temp_hooks_dir(self.temp_dir): + hook = ClickHook.open("test") + hook.install( + self.temp_dir, "org.example.package", "1.0", "test-app", "foo/bar", + user="test-user") + symlink_path = os.path.join( + self.temp_dir, "org.example.package_test-app_1.0.test") + target_path = os.path.join( + self.temp_dir, ".click", "users", "test-user", + "org.example.package", "foo", "bar") + self.assertTrue(os.path.islink(symlink_path)) + self.assertEqual(target_path, os.readlink(symlink_path)) + + @mock.patch("click.hooks.ClickHook._user_home") + def test_install_removes_previous(self, mock_user_home): + mock_user_home.return_value = "/home/test-user" + with mkfile(os.path.join(self.temp_dir, "test.hook")) as f: + print("User-Level: yes", file=f) + print("Pattern: %s/${id}.test" % self.temp_dir, file=f) + with temp_hooks_dir(self.temp_dir): + hook = ClickHook.open("test") + hook.install( + self.temp_dir, "org.example.package", "1.0", "test-app", "foo/bar", + user="test-user") + hook.install( + self.temp_dir, "org.example.package", "1.1", "test-app", "foo/bar", + user="test-user") + old_symlink_path = os.path.join( + self.temp_dir, "org.example.package_test-app_1.0.test") + symlink_path = os.path.join( + self.temp_dir, "org.example.package_test-app_1.1.test") + self.assertFalse(os.path.islink(old_symlink_path)) + self.assertTrue(os.path.islink(symlink_path)) + target_path = os.path.join( + self.temp_dir, ".click", "users", "test-user", + "org.example.package", "foo", "bar") + self.assertEqual(target_path, os.readlink(symlink_path)) + + @mock.patch("click.hooks.ClickHook._user_home") + def test_upgrade(self, mock_user_home): + mock_user_home.return_value = "/home/test-user" + with mkfile(os.path.join(self.temp_dir, "test.hook")) as f: + print("User-Level: yes", file=f) + print("Pattern: %s/${id}.test" % self.temp_dir, file=f) + symlink_path = os.path.join( + self.temp_dir, "org.example.package_test-app_1.0.test") + os.symlink("old-target", symlink_path) + with temp_hooks_dir(self.temp_dir): + hook = ClickHook.open("test") + hook.install( + self.temp_dir, "org.example.package", "1.0", "test-app", "foo/bar", + user="test-user") + target_path = os.path.join( + self.temp_dir, ".click", "users", "test-user", + "org.example.package", "foo", "bar") + self.assertTrue(os.path.islink(symlink_path)) + self.assertEqual(target_path, os.readlink(symlink_path)) + + @mock.patch("click.hooks.ClickHook._user_home") + def test_remove(self, mock_user_home): + mock_user_home.return_value = "/home/test-user" + with mkfile(os.path.join(self.temp_dir, "test.hook")) as f: + print("User-Level: yes", file=f) + print("Pattern: %s/${id}.test" % self.temp_dir, file=f) + symlink_path = os.path.join( + self.temp_dir, "org.example.package_test-app_1.0.test") + os.symlink("old-target", symlink_path) + with temp_hooks_dir(self.temp_dir): + hook = ClickHook.open("test") + hook.remove("org.example.package", "1.0", "test-app", user="test-user") + self.assertFalse(os.path.exists(symlink_path)) + + @mock.patch("click.hooks.ClickHook._user_home") + def test_install_all(self, mock_user_home): + mock_user_home.return_value = "/home/test-user" + with mkfile(os.path.join(self.temp_dir, "hooks", "new.hook")) as f: + print("User-Level: yes", file=f) + print("Pattern: %s/${id}.new" % self.temp_dir, file=f) + user_db = ClickUser(self.temp_dir, user="test-user") + with mkfile(os.path.join( + self.temp_dir, "test-1", "1.0", ".click", "info", + "test-1.manifest")) as f: + f.write(json.dumps({ + "maintainer": + b"Unic\xc3\xb3de ".decode("UTF-8"), + "hooks": {"test1-app": {"new": "target-1"}}, + })) + user_db["test-1"] = "1.0" + with mkfile(os.path.join( + self.temp_dir, "test-2", "2.0", ".click", "info", + "test-2.manifest")) as f: + f.write(json.dumps({ + "maintainer": + b"Unic\xc3\xb3de ".decode("UTF-8"), + "hooks": {"test1-app": {"new": "target-2"}}, + })) + user_db["test-2"] = "2.0" + with temp_hooks_dir(os.path.join(self.temp_dir, "hooks")): + hook = ClickHook.open("new") + hook.install_all(self.temp_dir) + path_1 = os.path.join(self.temp_dir, "test-1_test1-app_1.0.new") + self.assertTrue(os.path.lexists(path_1)) + self.assertEqual( + os.path.join( + self.temp_dir, ".click", "users", "test-user", "test-1", + "target-1"), + os.readlink(path_1)) + path_2 = os.path.join(self.temp_dir, "test-2_test1-app_2.0.new") + self.assertTrue(os.path.lexists(path_2)) + self.assertEqual( + os.path.join( + self.temp_dir, ".click", "users", "test-user", "test-2", + "target-2"), + os.readlink(path_2)) + + @mock.patch("click.hooks.ClickHook._user_home") + def test_remove_all(self, mock_user_home): + mock_user_home.return_value = "/home/test-user" + with mkfile(os.path.join(self.temp_dir, "hooks", "old.hook")) as f: + print("User-Level: yes", file=f) + print("Pattern: %s/${id}.old" % self.temp_dir, file=f) + user_db = ClickUser(self.temp_dir, user="test-user") + with mkfile(os.path.join( + self.temp_dir, "test-1", "1.0", ".click", "info", + "test-1.manifest")) as f: + f.write(json.dumps({"hooks": {"test1-app": {"old": "target-1"}}})) + user_db["test-1"] = "1.0" + os.symlink("1.0", os.path.join(self.temp_dir, "test-1", "current")) + path_1 = os.path.join(self.temp_dir, "test-1_test1-app_1.0.old") + os.symlink(os.path.join(user_db.path("test-1"), "target-1"), path_1) + with mkfile(os.path.join( + self.temp_dir, "test-2", "2.0", ".click", "info", + "test-2.manifest")) as f: + f.write(json.dumps({"hooks": {"test2-app": {"old": "target-2"}}})) + user_db["test-2"] = "2.0" + path_2 = os.path.join(self.temp_dir, "test-2_test2-app_2.0.old") + os.symlink(os.path.join(user_db.path("test-2"), "target-2"), path_2) + with temp_hooks_dir(os.path.join(self.temp_dir, "hooks")): + hook = ClickHook.open("old") + hook.remove_all(self.temp_dir) + self.assertFalse(os.path.exists(path_1)) + self.assertFalse(os.path.exists(path_2)) + + +class TestPackageInstallHooks(TestCase): + def setUp(self): + super(TestPackageInstallHooks, self).setUp() + self.use_temp_dir() + + def assert_has_calls_sparse(self, mock_obj, calls): + """Like mock.assert_has_calls, but allows other calls in between.""" + expected_calls = list(calls) + for call in mock_obj.mock_calls: + if not expected_calls: + return + if call == expected_calls[0]: + expected_calls.pop(0) + if expected_calls: + raise AssertionError( + "Calls not found.\nExpected: %r\nActual: %r" % + (calls, mock_obj.mock_calls)) + + def test_removes_old_hooks(self): + hooks_dir = os.path.join(self.temp_dir, "hooks") + with mkfile(os.path.join(hooks_dir, "unity.hook")) as f: + print("Pattern: %s/unity/${id}.scope" % self.temp_dir, file=f) + print("Single-Version: yes", file=f) + with mkfile(os.path.join(hooks_dir, "yelp-docs.hook")) as f: + print("Pattern: %s/yelp/docs-${id}.txt" % self.temp_dir, file=f) + print("Single-Version: yes", file=f) + print("Hook-Name: yelp", file=f) + with mkfile(os.path.join(hooks_dir, "yelp-other.hook")) as f: + print("Pattern: %s/yelp/other-${id}.txt" % self.temp_dir, file=f) + print("Single-Version: yes", file=f) + print("Hook-Name: yelp", file=f) + os.mkdir(os.path.join(self.temp_dir, "unity")) + unity_path = os.path.join(self.temp_dir, "unity", "test_app_1.0.scope") + os.symlink("dummy", unity_path) + os.mkdir(os.path.join(self.temp_dir, "yelp")) + yelp_docs_path = os.path.join( + self.temp_dir, "yelp", "docs-test_app_1.0.txt") + os.symlink("dummy", yelp_docs_path) + yelp_other_path = os.path.join( + self.temp_dir, "yelp", "other-test_app_1.0.txt") + os.symlink("dummy", yelp_other_path) package_dir = os.path.join(self.temp_dir, "test") with mkfile(os.path.join( package_dir, "1.0", ".click", "info", "test.manifest")) as f: f.write(json.dumps( - {"hooks": {"yelp": "foo.txt", "unity": "foo.scope"}})) + {"hooks": {"app": {"yelp": "foo.txt", "unity": "foo.scope"}}})) with mkfile(os.path.join( package_dir, "1.1", ".click", "info", "test.manifest")) as f: f.write(json.dumps({})) - run_hooks(self.temp_dir, "test", "1.0", "1.1") - self.assertEqual(2, mock_open.call_count) - mock_open.assert_has_calls([ - mock.call("unity"), - mock.call().remove("test"), - mock.call("yelp"), - mock.call().remove("test"), - ]) - - @mock.patch("click.hooks.ClickHook.open") - def test_installs_new_hooks(self, mock_open): + with temp_hooks_dir(hooks_dir): + package_install_hooks(self.temp_dir, "test", "1.0", "1.1") + self.assertFalse(os.path.lexists(unity_path)) + self.assertFalse(os.path.lexists(yelp_docs_path)) + self.assertFalse(os.path.lexists(yelp_other_path)) + + def test_installs_new_hooks(self): + hooks_dir = os.path.join(self.temp_dir, "hooks") + with mkfile(os.path.join(hooks_dir, "a.hook")) as f: + print("Pattern: %s/a/${id}.a" % self.temp_dir, file=f) + with mkfile(os.path.join(hooks_dir, "b-1.hook")) as f: + print("Pattern: %s/b/1-${id}.b" % self.temp_dir, file=f) + print("Hook-Name: b", file=f) + with mkfile(os.path.join(hooks_dir, "b-2.hook")) as f: + print("Pattern: %s/b/2-${id}.b" % self.temp_dir, file=f) + print("Hook-Name: b", file=f) + os.mkdir(os.path.join(self.temp_dir, "a")) + os.mkdir(os.path.join(self.temp_dir, "b")) package_dir = os.path.join(self.temp_dir, "test") with mkfile(os.path.join( package_dir, "1.0", ".click", "info", "test.manifest")) as f: f.write(json.dumps({"hooks": {}})) with mkfile(os.path.join( package_dir, "1.1", ".click", "info", "test.manifest")) as f: - f.write(json.dumps({"hooks": {"a": "foo.a", "b": "foo.b"}})) - run_hooks(self.temp_dir, "test", "1.0", "1.1") - self.assertEqual(2, mock_open.call_count) - mock_open.assert_has_calls([ - mock.call("a"), - mock.call().install(self.temp_dir, "test", "1.1", "foo.a"), - mock.call("b"), - mock.call().install(self.temp_dir, "test", "1.1", "foo.b"), - ]) - - @mock.patch("click.hooks.ClickHook.open") - def test_upgrades_existing_hooks(self, mock_open): + f.write(json.dumps( + {"hooks": {"app": {"a": "foo.a", "b": "foo.b"}}})) + with temp_hooks_dir(hooks_dir): + package_install_hooks(self.temp_dir, "test", "1.0", "1.1") + self.assertTrue(os.path.lexists( + os.path.join(self.temp_dir, "a", "test_app_1.1.a"))) + self.assertTrue(os.path.lexists( + os.path.join(self.temp_dir, "b", "1-test_app_1.1.b"))) + self.assertTrue(os.path.lexists( + os.path.join(self.temp_dir, "b", "2-test_app_1.1.b"))) + + def test_upgrades_existing_hooks(self): + hooks_dir = os.path.join(self.temp_dir, "hooks") + with mkfile(os.path.join(hooks_dir, "a.hook")) as f: + print("Pattern: %s/a/${id}.a" % self.temp_dir, file=f) + print("Single-Version: yes", file=f) + with mkfile(os.path.join(hooks_dir, "b-1.hook")) as f: + print("Pattern: %s/b/1-${id}.b" % self.temp_dir, file=f) + print("Single-Version: yes", file=f) + print("Hook-Name: b", file=f) + with mkfile(os.path.join(hooks_dir, "b-2.hook")) as f: + print("Pattern: %s/b/2-${id}.b" % self.temp_dir, file=f) + print("Single-Version: yes", file=f) + print("Hook-Name: b", file=f) + with mkfile(os.path.join(hooks_dir, "c.hook")) as f: + print("Pattern: %s/c/${id}.c" % self.temp_dir, file=f) + print("Single-Version: yes", file=f) + os.mkdir(os.path.join(self.temp_dir, "a")) + a_path = os.path.join(self.temp_dir, "a", "test_app_1.0.a") + os.symlink("dummy", a_path) + os.mkdir(os.path.join(self.temp_dir, "b")) + b_irrelevant_path = os.path.join( + self.temp_dir, "b", "1-test_other-app_1.0.b") + os.symlink("dummy", b_irrelevant_path) + b_1_path = os.path.join(self.temp_dir, "b", "1-test_app_1.0.b") + os.symlink("dummy", b_1_path) + b_2_path = os.path.join(self.temp_dir, "b", "2-test_app_1.0.b") + os.symlink("dummy", b_2_path) + os.mkdir(os.path.join(self.temp_dir, "c")) package_dir = os.path.join(self.temp_dir, "test") with mkfile(os.path.join( package_dir, "1.0", ".click", "info", "test.manifest")) as f: - f.write(json.dumps({"hooks": {"a": "foo.a", "b": "foo.b"}})) + f.write(json.dumps( + {"hooks": {"app": {"a": "foo.a", "b": "foo.b"}}})) with mkfile(os.path.join( package_dir, "1.1", ".click", "info", "test.manifest")) as f: f.write(json.dumps( - {"hooks": {"a": "foo.a", "b": "foo.b", "c": "foo.c"}})) - run_hooks(self.temp_dir, "test", "1.0", "1.1") - self.assertEqual(3, mock_open.call_count) - mock_open.assert_has_calls([ - mock.call("a"), - mock.call().install(self.temp_dir, "test", "1.1", "foo.a"), - mock.call("b"), - mock.call().install(self.temp_dir, "test", "1.1", "foo.b"), - ]) + {"hooks": { + "app": {"a": "foo.a", "b": "foo.b", "c": "foo.c"}} + })) + with temp_hooks_dir(hooks_dir): + package_install_hooks(self.temp_dir, "test", "1.0", "1.1") + self.assertFalse(os.path.lexists(a_path)) + self.assertTrue(os.path.lexists(b_irrelevant_path)) + self.assertFalse(os.path.lexists(b_1_path)) + self.assertFalse(os.path.lexists(b_2_path)) + self.assertTrue(os.path.lexists( + os.path.join(self.temp_dir, "a", "test_app_1.1.a"))) + self.assertTrue(os.path.lexists( + os.path.join(self.temp_dir, "b", "1-test_app_1.1.b"))) + self.assertTrue(os.path.lexists( + os.path.join(self.temp_dir, "b", "2-test_app_1.1.b"))) + self.assertTrue(os.path.lexists( + os.path.join(self.temp_dir, "c", "test_app_1.1.c"))) diff -Nru click-0.1.7~precise1~bzr150/click/tests/test_install.py click-0.3.1~precise1~test2/click/tests/test_install.py --- click-0.1.7~precise1~bzr150/click/tests/test_install.py 2013-07-19 03:16:19.000000000 +0000 +++ click-0.3.1~precise1~test2/click/tests/test_install.py 2013-08-13 08:12:35.000000000 +0000 @@ -38,6 +38,7 @@ from click.install import DebFile from click import install, osextras +from click.build import ClickBuilder from click.install import ClickInstaller, ClickInstallerPermissionDenied from click.preinst import static_preinst from click.tests.helpers import TestCase, mkfile, mock, touch @@ -67,16 +68,13 @@ def make_fake_package(self, control_fields=None, manifest=None, control_scripts=None, data_files=None): - """Build a fake package with given contents. - - We can afford to use dpkg-deb here since it's easy, just for testing. - """ + """Build a fake package with given contents.""" control_fields = {} if control_fields is None else control_fields control_scripts = {} if control_scripts is None else control_scripts data_files = [] if data_files is None else data_files - package_dir = os.path.join(self.temp_dir, "fake-package") - control_dir = os.path.join(package_dir, "DEBIAN") + data_dir = os.path.join(self.temp_dir, "fake-package") + control_dir = os.path.join(self.temp_dir, "DEBIAN") with mkfile(os.path.join(control_dir, "control")) as control: for key, value in control_fields.items(): print('%s: %s' % (key.title(), value), file=control) @@ -87,15 +85,12 @@ for name, contents in control_scripts.items(): with mkfile(os.path.join(control_dir, name)) as script: script.write(contents) + osextras.ensuredir(data_dir) for name in data_files: - touch(os.path.join(package_dir, name)) - package_path = '%s.click' % package_dir - with open("/dev/null", "w") as devnull: - env = dict(os.environ) - env["NO_PKG_MANGLE"] = "1" - subprocess.check_call( - ["dpkg-deb", "--nocheck", "-b", package_dir, package_path], - stdout=devnull, stderr=devnull, env=env) + touch(os.path.join(data_dir, name)) + package_path = '%s.click' % data_dir + ClickBuilder()._pack( + self.temp_dir, control_dir, data_dir, package_path) return package_path @contextmanager @@ -136,7 +131,7 @@ def test_audit_control_forbids_depends(self): path = self.make_fake_package( control_fields={ - "Click-Version": "0.1", + "Click-Version": "0.2", "Depends": "libc6", }) with closing(DebFile(filename=path)) as package, \ @@ -147,7 +142,7 @@ def test_audit_control_forbids_maintscript(self): path = self.make_fake_package( - control_fields={"Click-Version": "0.1"}, + control_fields={"Click-Version": "0.2"}, control_scripts={ "preinst": "#! /bin/sh\n", "postinst": "#! /bin/sh\n", @@ -162,7 +157,7 @@ def test_audit_control_requires_manifest(self): path = self.make_fake_package( - control_fields={"Click-Version": "0.1"}, + control_fields={"Click-Version": "0.2"}, control_scripts={"preinst": static_preinst}) with closing(DebFile(filename=path)) as package, \ self.make_framework("ubuntu-sdk-13.10"): @@ -172,7 +167,7 @@ def test_audit_control_invalid_manifest_json(self): path = self.make_fake_package( - control_fields={"Click-Version": "0.1"}, + control_fields={"Click-Version": "0.2"}, control_scripts={"manifest": "{", "preinst": static_preinst}) with closing(DebFile(filename=path)) as package, \ self.make_framework("ubuntu-sdk-13.10"): @@ -182,7 +177,7 @@ def test_audit_control_no_name(self): path = self.make_fake_package( - control_fields={"Click-Version": "0.1"}, + control_fields={"Click-Version": "0.2"}, manifest={}) with closing(DebFile(filename=path)) as package: self.assertRaisesRegex( @@ -191,7 +186,7 @@ def test_audit_control_name_bad_character(self): path = self.make_fake_package( - control_fields={"Click-Version": "0.1"}, + control_fields={"Click-Version": "0.2"}, manifest={"name": "../evil"}) with closing(DebFile(filename=path)) as package: self.assertRaisesRegex( @@ -201,7 +196,7 @@ def test_audit_control_no_version(self): path = self.make_fake_package( - control_fields={"Click-Version": "0.1"}, + control_fields={"Click-Version": "0.2"}, manifest={"name": "test-package"}) with closing(DebFile(filename=path)) as package: self.assertRaisesRegex( @@ -210,7 +205,7 @@ def test_audit_control_no_framework(self): path = self.make_fake_package( - control_fields={"Click-Version": "0.1"}, + control_fields={"Click-Version": "0.2"}, manifest={"name": "test-package", "version": "1.0"}, control_scripts={"preinst": static_preinst}) with closing(DebFile(filename=path)) as package: @@ -220,7 +215,7 @@ def test_audit_control_missing_framework(self): path = self.make_fake_package( - control_fields={"Click-Version": "0.1"}, + control_fields={"Click-Version": "0.2"}, manifest={ "name": "test-package", "version": "1.0", @@ -235,7 +230,7 @@ def test_audit_control_missing_framework_force(self): path = self.make_fake_package( - control_fields={"Click-Version": "0.1"}, + control_fields={"Click-Version": "0.2"}, manifest={ "name": "test-package", "version": "1.0", @@ -247,7 +242,7 @@ def test_audit_passes_correct_package(self): path = self.make_fake_package( - control_fields={"Click-Version": "0.1"}, + control_fields={"Click-Version": "0.2"}, manifest={ "name": "test-package", "version": "1.0", @@ -260,7 +255,7 @@ def test_no_write_permission(self): path = self.make_fake_package( - control_fields={"Click-Version": "0.1"}, + control_fields={"Click-Version": "0.2"}, manifest={ "name": "test-package", "version": "1.0", @@ -281,8 +276,8 @@ @skipUnless( os.path.exists(ClickInstaller(None)._preload_path()), "preload bits not built; installing packages will fail") - @mock.patch("click.install.run_hooks") - def test_install(self, mock_run_hooks): + @mock.patch("click.install.package_install_hooks") + def test_install(self, mock_package_install_hooks): path = self.make_fake_package( control_fields={ "Package": "test-package", @@ -290,7 +285,7 @@ "Architecture": "all", "Maintainer": "Foo Bar ", "Description": "test", - "Click-Version": "0.1", + "Click-Version": "0.2", }, manifest={ "name": "test-package", @@ -313,7 +308,9 @@ self.assertCountEqual([".click", "foo"], os.listdir(inst_dir)) status_path = os.path.join(inst_dir, ".click", "status") with open(status_path) as status_file: - status = list(Deb822.iter_paragraphs(status_file)) + # .readlines() avoids the need for a python-apt backport to + # Ubuntu 12.04 LTS. + status = list(Deb822.iter_paragraphs(status_file.readlines())) self.assertEqual(1, len(status)) self.assertEqual({ "Package": "test-package", @@ -322,9 +319,9 @@ "Architecture": "all", "Maintainer": "Foo Bar ", "Description": "test", - "Click-Version": "0.1", + "Click-Version": "0.2", }, status[0]) - mock_run_hooks.assert_called_once_with( + mock_package_install_hooks.assert_called_once_with( root, "test-package", None, "1.0") @skipUnless( @@ -351,7 +348,7 @@ "Architecture": "all", "Maintainer": "Foo Bar ", "Description": "test", - "Click-Version": "0.1", + "Click-Version": "0.2", }, manifest={ "name": "test-package", @@ -373,8 +370,8 @@ @skipUnless( os.path.exists(ClickInstaller(None)._preload_path()), "preload bits not built; installing packages will fail") - @mock.patch("click.install.run_hooks") - def test_upgrade(self, mock_run_hooks): + @mock.patch("click.install.package_install_hooks") + def test_upgrade(self, mock_package_install_hooks): path = self.make_fake_package( control_fields={ "Package": "test-package", @@ -382,7 +379,7 @@ "Architecture": "all", "Maintainer": "Foo Bar ", "Description": "test", - "Click-Version": "0.1", + "Click-Version": "0.2", }, manifest={ "name": "test-package", @@ -408,7 +405,9 @@ self.assertCountEqual([".click", "foo"], os.listdir(inst_dir)) status_path = os.path.join(inst_dir, ".click", "status") with open(status_path) as status_file: - status = list(Deb822.iter_paragraphs(status_file)) + # .readlines() avoids the need for a python-apt backport to + # Ubuntu 12.04 LTS. + status = list(Deb822.iter_paragraphs(status_file.readlines())) self.assertEqual(1, len(status)) self.assertEqual({ "Package": "test-package", @@ -417,7 +416,7 @@ "Architecture": "all", "Maintainer": "Foo Bar ", "Description": "test", - "Click-Version": "0.1", + "Click-Version": "0.2", }, status[0]) - mock_run_hooks.assert_called_once_with( + mock_package_install_hooks.assert_called_once_with( root, "test-package", "1.0", "1.1") diff -Nru click-0.1.7~precise1~bzr150/click/user.py click-0.3.1~precise1~test2/click/user.py --- click-0.1.7~precise1~bzr150/click/user.py 2013-07-19 03:16:19.000000000 +0000 +++ click-0.3.1~precise1~test2/click/user.py 2013-08-13 08:12:35.000000000 +0000 @@ -35,9 +35,10 @@ __metaclass__ = type __all__ = [ 'ClickUser', + 'ClickUsers', ] -from collections import MutableMapping +from collections import Mapping, MutableMapping from contextlib import contextmanager import os import pwd @@ -45,25 +46,23 @@ from click import osextras -class ClickUser(MutableMapping): - def __init__(self, root, user=None): +def _db_top(root): + # This is deliberately outside any user's home directory so that it can + # safely be iterated etc. as root. + return os.path.join(root, ".click", "users") + + +def _db_for_user(root, user): + return os.path.join(_db_top(root), user) + + +class ClickUsers(Mapping): + def __init__(self, root): self.root = root - if user is None: - user = pwd.getpwuid(os.getuid()).pw_name - self.user = user - self._user_pw = None self._click_pw = None - # This is deliberately outside the user's home directory so that it + # This is deliberately outside any user's home directory so that it # can safely be iterated etc. as root. - self._db_top = os.path.join(self.root, ".click", "users") - self._db = os.path.join(self._db_top, self.user) - self._dropped_privileges_count = 0 - - @property - def user_pw(self): - if self._user_pw is None: - self._user_pw = pwd.getpwnam(self.user) - return self._user_pw + self._db = _db_top(self.root) @property def click_pw(self): @@ -73,18 +72,59 @@ def _ensure_db(self): create = [] - path = self._db_top + path = self._db while not os.path.exists(path): create.append(path) path = os.path.dirname(path) for path in reversed(create): os.mkdir(path) - if os.getuid() == 0: + if os.geteuid() == 0: pw = self.click_pw os.chown(path, pw.pw_uid, pw.pw_gid) + + def __iter__(self): + for entry in osextras.listdir_force(self._db): + if os.path.isdir(os.path.join(self._db, entry)): + yield entry + + def __len__(self): + count = 0 + for entry in self: + count += 1 + return count + + def __getitem__(self, user): + path = _db_for_user(self.root, user) + if os.path.isdir(path): + return ClickUser(self.root, user=user) + else: + raise KeyError + + +class ClickUser(MutableMapping): + def __init__(self, root, user=None): + self.root = root + if user is None: + user = pwd.getpwuid(os.getuid()).pw_name + self.user = user + self._users = None + self._user_pw = None + self._db = _db_for_user(self.root, self.user) + self._dropped_privileges_count = 0 + + @property + def user_pw(self): + if self._user_pw is None: + self._user_pw = pwd.getpwnam(self.user) + return self._user_pw + + def _ensure_db(self): + if self._users is None: + self._users = ClickUsers(self.root) + self._users._ensure_db() if not os.path.exists(self._db): os.mkdir(self._db) - if os.getuid() == 0: + if os.geteuid() == 0: pw = self.user_pw os.chown(self._db, pw.pw_uid, pw.pw_gid) @@ -117,10 +157,14 @@ self._regain_privileges() def __iter__(self): + # We cannot be lazy here, because otherwise calling code may + # unwittingly end up with dropped privileges. + entries = [] with self._dropped_privileges(): for entry in osextras.listdir_force(self._db): if os.path.islink(os.path.join(self._db, entry)): - yield entry + entries.append(entry) + return iter(entries) def __len__(self): count = 0 @@ -137,15 +181,21 @@ raise KeyError def __setitem__(self, package, version): + # Circular import. + from click.hooks import package_install_hooks + path = os.path.join(self._db, package) new_path = os.path.join(self._db, ".%s.new" % package) self._ensure_db() + old_version = self.get(package) with self._dropped_privileges(): target = os.path.join(self.root, package, version) if not os.path.exists(target): raise ValueError("%s does not exist" % target) osextras.symlink_force(target, new_path) os.rename(new_path, path) + package_install_hooks( + self.root, package, old_version, version, user=self.user) def __delitem__(self, package): path = os.path.join(self._db, package) @@ -154,6 +204,7 @@ osextras.unlink_force(path) else: raise KeyError + # TODO: run hooks for removal def path(self, package): return os.path.join(self._db, package) diff -Nru click-0.1.7~precise1~bzr150/click/versions.py click-0.3.1~precise1~test2/click/versions.py --- click-0.1.7~precise1~bzr150/click/versions.py 2013-07-19 03:16:19.000000000 +0000 +++ click-0.3.1~precise1~test2/click/versions.py 2013-08-13 08:12:35.000000000 +0000 @@ -15,4 +15,4 @@ """Click package versioning.""" -spec_version = "0.1" +spec_version = "0.3" diff -Nru click-0.1.7~precise1~bzr150/debhelper/dh_click click-0.3.1~precise1~test2/debhelper/dh_click --- click-0.1.7~precise1~bzr150/debhelper/dh_click 2013-07-19 03:16:19.000000000 +0000 +++ click-0.3.1~precise1~test2/debhelper/dh_click 2013-08-13 08:12:35.000000000 +0000 @@ -42,6 +42,13 @@ Do not modify F/F scripts. +=item B<--name=>I + +Install the hook using the filename I instead of the default filename, +which is the package name. When this parameter is used, B looks +for and installs files named F, instead of the +usual F. + =back =head1 EXAMPLES @@ -66,16 +73,20 @@ foreach my $package (@{$dh{DOPACKAGES}}) { my $tmp=tmpdir($package); my $click_hook=pkgfile($package,"click-hook"); + my $hookname=$package; + if (defined $dh{NAME}) { + $hookname=$dh{NAME}; + } if ($click_hook ne '') { if (! -d "$tmp/usr/share/click/hooks") { doit("install","-d","$tmp/usr/share/click/hooks"); } - doit("install","-p","-m644",$click_hook,"$tmp/usr/share/click/hooks/$package.hook"); + doit("install","-p","-m644",$click_hook,"$tmp/usr/share/click/hooks/$hookname.hook"); if (! $dh{NOSCRIPTS}) { - autoscript($package,"postinst","postinst-click","s/#PACKAGE#/$package/"); - autoscript($package,"prerm","prerm-click","s/#PACKAGE#/$package/"); + autoscript($package,"postinst","postinst-click","s/#HOOK#/$hookname/"); + autoscript($package,"prerm","prerm-click","s/#HOOK#/$hookname/"); } } } diff -Nru click-0.1.7~precise1~bzr150/debhelper/postinst-click click-0.3.1~precise1~test2/debhelper/postinst-click --- click-0.1.7~precise1~bzr150/debhelper/postinst-click 2013-07-19 03:16:19.000000000 +0000 +++ click-0.3.1~precise1~test2/debhelper/postinst-click 2013-08-13 08:12:35.000000000 +0000 @@ -1,3 +1,3 @@ if [ "$1" = "configure" ] && which click >/dev/null 2>&1; then - click hook install #PACKAGE# + click hook install #HOOK# fi diff -Nru click-0.1.7~precise1~bzr150/debhelper/prerm-click click-0.3.1~precise1~test2/debhelper/prerm-click --- click-0.1.7~precise1~bzr150/debhelper/prerm-click 2013-07-19 03:16:19.000000000 +0000 +++ click-0.3.1~precise1~test2/debhelper/prerm-click 2013-08-13 08:12:35.000000000 +0000 @@ -1,3 +1,3 @@ if which click >/dev/null 2>&1; then - click hook remove #PACKAGE# + click hook remove #HOOK# fi diff -Nru click-0.1.7~precise1~bzr150/debian/changelog click-0.3.1~precise1~test2/debian/changelog --- click-0.1.7~precise1~bzr150/debian/changelog 2013-07-19 04:17:51.000000000 +0000 +++ click-0.3.1~precise1~test2/debian/changelog 2013-08-13 08:36:03.000000000 +0000 @@ -1,8 +1,149 @@ -click (0.1.7~precise1~bzr150) precise; urgency=low +click (0.3.1~precise1~test2) precise; urgency=low - * Releasing in the SDK PPA + [ Colin Watson ] + * Fix some more failures with mock 0.7.2. + * Work around the lack of a python-apt backport of + apt_pkg.TagFile(sequence, bytes=True) to precise. - -- Zoltán Balogh Fri, 19 Jul 2013 06:34:32 +0300 + [ Jamie Strandboge ] + * Codify allowed characters for "application ID". + * Fix typos in apparmor hook example. + + -- Colin Watson Tue, 13 Aug 2013 10:10:11 +0200 + +click (0.3.0) saucy; urgency=low + + * Insert a new "_click-binary" ar member immediately after + "debian-binary"; this allows detecting the MIME type of a Click package + even when it doesn't have the extension ".click" (LP: #1205346). + * Declare the application/x-click MIME type, since the shared-mime-info + upstream would rather not take the patch there at this point + (https://bugs.freedesktop.org/show_bug.cgi?id=66689). + * Make removal of old links for single-version hooks work even when the + application ID is not a prefix of the pattern's basename. + * Add an optional Hook-Name field to hook files, thereby allowing multiple + hooks to attach to the same virtual name. + * Rename click's own "desktop" hook to "click-desktop", making use of the + new Hook-Name facility. + + -- Colin Watson Tue, 06 Aug 2013 11:08:46 +0100 + +click (0.2.10) saucy; urgency=low + + * Force click's stdout encoding to UTF-8 regardless of the locale. + * Don't encode non-ASCII characters in JSON dumps. + * Treat manifests as UTF-8. + + -- Colin Watson Tue, 30 Jul 2013 15:14:16 +0100 + +click (0.2.9) saucy; urgency=low + + * Tolerate dangling source symlinks in "click desktophook". + * Handle the case where the clickpkg user cannot read the .click file, + using some LD_PRELOAD trickery to allow passing it as a file descriptor + opened by the privileged process (LP: #1204523). + * Remove old links for single-version hooks when installing new versions + (LP: #1206115). + + -- Colin Watson Mon, 29 Jul 2013 16:56:42 +0100 + +click (0.2.8) saucy; urgency=low + + * Check in advance whether the root is writable by the clickpkg user, not + just by root, and do so in a way less vulnerable to useful exception + text being eaten by a subprocess preexec_fn (LP: #1204570). + * Actually install + /var/lib/polkit-1/localauthority/10-vendor.d/com.ubuntu.click.pkla in + the packagekit-plugin-click binary package. + + -- Colin Watson Thu, 25 Jul 2013 17:40:49 +0100 + +click (0.2.7) saucy; urgency=low + + * Fix error message when rejecting "_" from a package name or version + (LP: #1204560). + + -- Colin Watson Wed, 24 Jul 2013 16:42:59 +0100 + +click (0.2.6) saucy; urgency=low + + * Adjust written .desktop files to avoid tickling some bugs in Unity 8's + parsing. + + -- Colin Watson Wed, 24 Jul 2013 08:03:08 +0100 + +click (0.2.5) saucy; urgency=low + + * Ensure that ~/.local/share/applications exists if we need to write any + .desktop files. + + -- Colin Watson Wed, 24 Jul 2013 07:44:44 +0100 + +click (0.2.4) saucy; urgency=low + + * Mangle Icon in .desktop files to point to an absolute path within the + package unpack directory if necessary. + * Add a "--" separator between aa-exec's options and the subsidiary + command, per Jamie Strandboge. + + -- Colin Watson Tue, 23 Jul 2013 23:38:29 +0100 + +click (0.2.3) saucy; urgency=low + + * Set Path in generated .desktop files to the top-level package directory. + * Revert part of geteuid() change in 0.2.2; ClickUser._drop_privileges and + ClickUser._regain_privileges need to check the real UID, or else they + will never regain privileges. + * When running a hook, set HOME to the home directory of the user the hook + is running as. + + -- Colin Watson Tue, 23 Jul 2013 22:57:03 +0100 + +click (0.2.2) saucy; urgency=low + + * dh_click: Support --name option. + * Avoid ClickUser.__iter__ infecting its caller with dropped privileges. + * Use geteuid() rather than getuid() in several places to check whether we + need to drop or regain privileges. + * Add a user-level hook to create .desktop files in + ~/.local/share/applications/. (This should probably move to some other + package at some point.) + + -- Colin Watson Tue, 23 Jul 2013 19:36:44 +0100 + +click (0.2.1) saucy; urgency=low + + * Fix "click help list". + * Remove HOME from environment when running dpkg, so that it doesn't try + to read .dpkg.cfg from it (which may fail when dropping privileges from + root and produce a warning message). + * Refuse to install .click directories at any level, not just the top. + * Add "click pkgdir" command to print the top-level package directory from + either a package name or a path within a package; based on work by Ted + Gould, for which thanks. + + -- Colin Watson Mon, 22 Jul 2013 09:36:19 +0100 + +click (0.2.0) saucy; urgency=low + + * Revise and implement hooks specification. While many things have + changed, the previous version was never fully implemented. However, I + have incremented the default Click-Version value to 0.2 to reflect the + design work. + - The "hooks" manifest key now contains a dictionary keyed by + application name. This means manifest authors have to repeat + themselves much less in common cases. + - There is now an explicit distinction between system-level and + user-level hooks. System-level hooks may reflect multiple concurrent + versions, and require a user name. + - Hook symlinks are now named by a combination of the Click package + name, the application name, and the Click package version. + - The syntax of Pattern has changed to make it easier to extend with new + substitutions. + * Reject '_' and '/' characters in all of package name, application name, + and package version. + + -- Colin Watson Fri, 19 Jul 2013 13:11:31 +0100 click (0.1.7) saucy; urgency=low diff -Nru click-0.1.7~precise1~bzr150/debian/click.click-desktop.click-hook click-0.3.1~precise1~test2/debian/click.click-desktop.click-hook --- click-0.1.7~precise1~bzr150/debian/click.click-desktop.click-hook 1970-01-01 00:00:00.000000000 +0000 +++ click-0.3.1~precise1~test2/debian/click.click-desktop.click-hook 2013-08-13 08:12:35.000000000 +0000 @@ -0,0 +1,4 @@ +User-Level: yes +Pattern: ${home}/.local/share/click/hooks/desktop/${id}.desktop +Exec: click desktophook +Hook-Name: desktop diff -Nru click-0.1.7~precise1~bzr150/debian/click.sharedmimeinfo click-0.3.1~precise1~test2/debian/click.sharedmimeinfo --- click-0.1.7~precise1~bzr150/debian/click.sharedmimeinfo 1970-01-01 00:00:00.000000000 +0000 +++ click-0.3.1~precise1~test2/debian/click.sharedmimeinfo 2013-08-13 08:12:35.000000000 +0000 @@ -0,0 +1,16 @@ + + + + Click package + + + + + + + + + + + + diff -Nru click-0.1.7~precise1~bzr150/debian/packagekit-plugin-click.install click-0.3.1~precise1~test2/debian/packagekit-plugin-click.install --- click-0.1.7~precise1~bzr150/debian/packagekit-plugin-click.install 2013-07-19 04:00:43.000000000 +0000 +++ click-0.3.1~precise1~test2/debian/packagekit-plugin-click.install 2013-08-13 08:33:06.000000000 +0000 @@ -1,2 +1,3 @@ usr/lib/packagekit-plugins/*.so usr/share/polkit-1/actions +var/lib/polkit-1/localauthority/10-vendor.d diff -Nru click-0.1.7~precise1~bzr150/debian/rules click-0.3.1~precise1~test2/debian/rules --- click-0.1.7~precise1~bzr150/debian/rules 2013-07-19 03:16:19.000000000 +0000 +++ click-0.3.1~precise1~test2/debian/rules 2013-08-13 08:12:35.000000000 +0000 @@ -44,5 +44,9 @@ --no-compile -O0 --install-layout=deb; \ done +override_dh_install: + dh_install + DH_AUTOSCRIPTDIR=debhelper debhelper/dh_click --name=click-desktop + # Sphinx documentation is architecture-independent. override_dh_sphinxdoc-arch: diff -Nru click-0.1.7~precise1~bzr150/doc/file-format.rst click-0.3.1~precise1~test2/doc/file-format.rst --- click-0.1.7~precise1~bzr150/doc/file-format.rst 2013-07-19 03:16:19.000000000 +0000 +++ click-0.3.1~precise1~test2/doc/file-format.rst 2013-08-13 08:12:35.000000000 +0000 @@ -1,5 +1,5 @@ ======================================== -"Click" package file format, version 0.1 +"Click" package file format, version 0.3 ======================================== This specification covers a packaging format intended for use by @@ -23,6 +23,13 @@ control and data tar archives, as for .deb packages: see deb(5) for full details. +The deb(5) format permits the insertion of underscore-prefixed ar members, +so a "_click-binary" member should be inserted immediately after +"debian-binary"; its contents should be the current version number of this +specification followed by a newline. This makes it possible to assign a +MIME type to Click packages without having to rely solely on their +extension. + Despite the similar format, the file extension for these packages is .click, to discourage attempts to install using dpkg directly (although it is still possible to use dpkg to inspect these files). Click packages should not be @@ -72,17 +79,18 @@ the value of "framework" does not declare a framework implemented by the system on which the package is being installed. -The value of "name" identifies the application; every package in the app -store has a unique "name" identifier, and the app store will reject clashes. -It is the developer's responsibility to choose a unique identifier. The -recommended approach is to follow the Java package name convention, i.e. -"com.mydomain.myapp", starting with the reverse of an Internet domain name -owned by the person or organisation developing the application; note that it -is not necessary for the application to contain any Java code in order to -use this convention. +The value of "name" identifies the application, following Debian source +package name rules; every package in the app store has a unique "name" +identifier, and the app store will reject clashes. It is the developer's +responsibility to choose a unique identifier. The recommended approach is +to follow the Java package name convention, i.e. "com.mydomain.myapp", +starting with the reverse of an Internet domain name owned by the person or +organisation developing the application; note that it is not necessary for +the application to contain any Java code in order to use this convention. The value of "version" provides a unique version for the application, -following Debian version numbering rules. +following Debian version numbering rules. See deb-version(5) for full +details. For future expansion (e.g. applications that require multiple frameworks), the syntax of "framework" is formally that of a Debian dependency @@ -96,6 +104,7 @@ * title: short (one-line) synopsis of the application * description: extended description of the application; may be multi-paragraph + * hooks: see :doc:`hooks` Keys beginning with the two characters "x-" are reserved for local extensions: this file format will never define such keys to have any diff -Nru click-0.1.7~precise1~bzr150/doc/hooks.rst click-0.3.1~precise1~test2/doc/hooks.rst --- click-0.1.7~precise1~bzr150/doc/hooks.rst 2013-07-19 03:16:19.000000000 +0000 +++ click-0.3.1~precise1~test2/doc/hooks.rst 2013-08-13 08:12:35.000000000 +0000 @@ -2,6 +2,9 @@ Hooks ===== +Rationale +--------- + Of course, any sensible packaging format needs a hook mechanism of some kind; just unpacking a filesystem tarball isn't going to cut it. But part of the point of Click packages is to make packages easier to audit by @@ -36,96 +39,177 @@ on the system dpkg database, which is large and therefore slow. Let us consider an example of the sort that might in future be delivered as -a Click package, and one which is simple but not too simple. -unity-scope-manpages (ignoring its dependencies) delivers a plugin for the -unity help lens (/usr/share/unity/lenses/help/unity-scope-manpages.scope) -and a D-Bus service -(/usr/share/dbus-1/services/unity-scope-manpages.service). These are -consumed by unity-lens-help and dbus respectively, and each lists the -corresponding directory looking for files to consume. +a Click package, and one which is simple but not too simple. Our example +package (com.ubuntu.example) delivers an AppArmor profile and two .desktop +files. These are consumed by apparmor and desktop-integration (TBD) +respectively, and each lists the corresponding directory looking for files +to consume. We must assume that in the general case it will be at least inconvenient to cause the integrated-with packages to look in multiple directories, especially when the list of possible directories is not fixed, so we need a way to cause files to exist in those directories. On the other hand, we cannot unpack directly into those directories, because that takes us back to -using dpkg itself. - -What we can do with reasonable safety is populate symlink farms. As a -strawman proposal, consider the following: +using dpkg itself, and is incompatible with system image updates where the +root file system is read-only. What we can do with reasonable safety is +populate symlink farms. + +Specification +------------- + + * Only system packages (i.e. .debs) may declare hooks. Click packages must + be declarative in that they may not include code executed outside + AppArmor confinement, which precludes declaring hooks. + + * "System-level hooks" are those which operate on the full set of installed + package/version combinations. They may run as any (system) user. + (Example: AppArmor profile handling.) + + * "User-level hooks" are those which operate on the set of packages + registered by a given user. They run as that user, and thus would + generally be expected to keep their state in the user's home directory or + some similar user-owned file system location. (Example: desktop file + handling.) + + * System-level and user-level hooks share a namespace. + + * A Click package may contain one or more applications (the common case + will be only one). Each application has a name. + + * An "application ID" is a string unique to each application instance: it + is made up of the Click package name, the application name (must consist + only of characters for a Debian source package name, Debian version and + [A-Z]), and the Click package version joined by underscores, e.g. + ``com.ubuntu.clock_alarm_0.1``. * An integrated-with system package may add ``*.hook`` files to - /usr/share/click/hooks/. These are standard Debian-style control files - with the following keys: + ``/usr/share/click/hooks/``. These are standard Debian-style control + files with the following keys: - Pattern: (required) - Exec: (optional) - Trigger: yes (optional) - - The value of Pattern is a printf format string which must contain exactly - one %s substitution: the package manager substitutes the unique Click - package name (e.g. com.example.app) into it to get the target path. On - install, it creates the target path as a symlink to a path provided by - the Click package; on upgrade, it changes the target path to be a symlink - to the path in the new version of the Click package; on removal, it - unlinks the target path. - - If the Exec key is present, its value is executed as if passed to the - shell after the above symlink is created. A non-zero exit status is an - error; hook implementors must be careful to make commands in Exec fields - robust. Note that this command intentionally takes no arguments, and - will be run on install, upgrade, and removal; it must be written such - that it causes the system to catch up with the current state of all - installed hooks. Exec commands must be idempotent. - - For the optional Trigger key, see below. - - * A Click package may include a "hooks" entry in its manifest (exact format - TBD). If present, it must contain a mapping of keys to values. The keys - are used to look up ``*.hook`` files with matching base names. The - values are symlink target paths used by the package manager when creating - symlinks according to the Pattern field in ``*.hook`` files. + User-Level: yes (optional) + If the ``User-Level`` key is present with the value ``yes``, the hook + is a user-level hook. + + Pattern: (required) + The value of ``Pattern`` is a string containing one or more + substitution placeholders, as follows: + + ``${id}`` + The application ID. + + ``${user}`` + The user name (user-level hooks only). + + ``${home}`` + The user's home directory (user-level hooks only). + + ``$$`` + The character '``$``'. + + At least one ``${id}`` substitution is required. For user-level hooks, + at least one of ``${user}`` and ``${home}`` must be present. + + On install, the package manager creates the target path as a symlink to + a path provided by the Click package; on upgrade, it changes the target + path to be a symlink to the path in the new version of the Click + package; on removal, it unlinks the target path. + + The terms "install", "upgrade", and "removal" are taken to refer to the + status of the hook rather than of the package. That is, when upgrading + between two versions of a package, if the old version uses a given hook + but the new version does not, then that is a removal; if the old + version does not use a given hook but the new version does, then that + is an install; if both versions use a given hook, then that is an + upgrade. + + For system-level hooks, one target path exists for each unpacked + version, unless "``Single-Version: yes``" is used (see below). For + user-level hooks, a target path exists only for the current version + registered by each user for each package. + + Upgrades of user-level hooks may leave the symlink pointed at the same + target (since the target will itself be via a ``current`` symlink in + the user registration directory). ``Exec`` commands in hooks should + take care to check the modification timestamp of the target. + + Exec: (optional) + If the ``Exec`` key is present, its value is executed as if passed to + the shell after the above symlink is modified. A non-zero exit status + is an error; hook implementors must be careful to make commands in + ``Exec`` fields robust. Note that this command intentionally takes no + arguments, and will be run on install, upgrade, and removal; it must be + written such that it causes the system to catch up with the current + state of all installed hooks. ``Exec`` commands must be idempotent. + + Trigger: yes (optional) + It will often be valuable to execute a dpkg trigger after installing a + Click package to avoid code duplication between system and Click + package handling, although we must do so asynchronously and any errors + must not block the installation of Click packages. If "``Trigger: + yes``" is set in a ``*.hook`` file, then "``click install``" will + activate an asynchronous D-Bus service at the end of installation, + passing the names of all the changed paths resulting from Pattern key + expansions; this will activate any file triggers matching those paths, + and process all the packages that enter the triggers-pending state as a + result. + + User: (required, system-level hooks only) + System-level hooks are run as the user whose name is specified as the + value of ``User``. There is intentionally no default for this key, to + encourage hook authors to run their hooks with the least appropriate + privilege. + + Single-Version: yes (optional, system-level hooks only) + By default, system-level hooks support multiple versions of packages, + so target paths may exist at multiple versions. "``Single-Version: + yes``" causes only the current version of each package to have a target + path. + + Hook-Name: (optional) + The value of ``Hook-Name`` is the name that Click packages may use to + attach to this hook. By default, this is the base name of the + ``*.hook`` file, with the ``.hook`` extension removed. + + Multiple hooks may use the same hook-name, in which case all those + hooks will be run when installing, upgrading, or removing a Click + package that attaches to that name. + + * A Click package may attach to zero or more hooks, by including a "hooks" + entry in its manifest. If present, this must be a dictionary mapping + application names to hook sets; each hook set is itself a dictionary + mapping hook names to paths. The hook names are used to look up + ``*.hook`` files with matching hook-names (see ``Hook-Name`` above). The + paths are relative to the directory where the Click package is unpacked, + and are used as symlink targets by the package manager when creating + symlinks according to the ``Pattern`` field in ``*.hook`` files. * There is a dh_click program which installs the ``*.hook`` files in system packages and adds maintainer script fragments to cause click to catch up with any newly-provided hooks. It may be invoked using ``dh $@ --with click``. - * It will often be valuable to execute a dpkg trigger after installing a - Click package to avoid code duplication between system and Click package - handling, although we must do so asynchronously and any errors must not - block the installation of Click packages. If "Trigger: yes" is set in a - ``*.hook`` file, then "click install" will activate an asynchronous D-Bus - service at the end of installation, passing the names of all the changed - paths resulting from Pattern key expansions; this will activate any file - triggers matching those paths, and process all the packages that enter - the triggers-pending state as a result. - - * The terms "install", "upgrade", and "removal" are taken to refer to the - status of the hook rather than of the package. That is, when upgrading - between two versions of a package, if the old version uses a given hook - but the new version does not, then that is a removal; if the old version - does not use a given hook but the new version does, then that is an - install; if both versions use a given hook, then that is an upgrade. - -Thus, a worked example would have:: - - /usr/share/click/hooks/unity-lens-help.hook - Pattern: /usr/share/unity/lenses/help/click-%s.scope - # unity-lens-help-update is fictional, shown for the sake of exposition - Exec: unity-lens-help-update +Examples +-------- - /usr/share/click/hooks/dbus-service.hook - Pattern: /usr/share/dbus-1/services/click-%s.service +:: - com.ubuntu.unity-scope-manpages/manifest.json: + /usr/share/click/hooks/apparmor.hook: + Pattern: /var/lib/apparmor/clicks/${id}.json + Exec: /usr/bin/aa-clickhook + User: root + + /usr/share/click/hooks/click-desktop.hook: + User-Level: yes + Pattern: /opt/click.ubuntu.com/.click/desktop-files/${user}_${id}.desktop + Exec: click desktophook + Hook-Name: desktop + + com.ubuntu.example/manifest.json: "hooks": { - "unity-lens-help": "help/unity-scope-manpages.scope", - "dbus-service": "services/unity-scope-manpages.service", + "example-app": { + "apparmor": "apparmor/example-app.json", + "desktop": "example-app.desktop", + } } TODO: copy rather than symlink, for additional robustness? - -TODO: D-Bus services are an awkward case because they contain a full path in -the Exec line; this will probably require some kind of declarative -substitution capability too diff -Nru click-0.1.7~precise1~bzr150/doc/todo.rst click-0.3.1~precise1~test2/doc/todo.rst --- click-0.1.7~precise1~bzr150/doc/todo.rst 2013-07-19 03:16:19.000000000 +0000 +++ click-0.3.1~precise1~test2/doc/todo.rst 2013-08-13 08:12:35.000000000 +0000 @@ -25,6 +25,8 @@ * define exit statuses for "click install" + * command to generate manifest template, like ``dh_make`` + Delta updates ============= diff -Nru click-0.1.7~precise1~bzr150/pk-plugin/pk-plugin-click.c click-0.3.1~precise1~test2/pk-plugin/pk-plugin-click.c --- click-0.1.7~precise1~bzr150/pk-plugin/pk-plugin-click.c 2013-07-19 03:16:19.000000000 +0000 +++ click-0.3.1~precise1~test2/pk-plugin/pk-plugin-click.c 2013-08-13 08:12:35.000000000 +0000 @@ -48,9 +48,6 @@ static gboolean click_is_click_package (const gchar *filename) { - /* This requires this patch to shared-mime-info: - * https://bugs.freedesktop.org/show_bug.cgi?id=66689 - */ gboolean ret = FALSE; GFile *file; GFileInfo *info = NULL; diff -Nru click-0.1.7~precise1~bzr150/preload/clickpreload.c click-0.3.1~precise1~test2/preload/clickpreload.c --- click-0.1.7~precise1~bzr150/preload/clickpreload.c 2013-07-19 03:16:19.000000000 +0000 +++ click-0.3.1~precise1~test2/preload/clickpreload.c 2013-08-13 08:12:35.000000000 +0000 @@ -37,6 +37,7 @@ static int (*libc_chown) (const char *, uid_t, gid_t) = (void *) 0; static int (*libc_execvp) (const char *, char * const []) = (void *) 0; static int (*libc_fchown) (int, uid_t, gid_t) = (void *) 0; +static FILE *(*libc_fopen) (const char *, const char *) = (void *) 0; static struct group *(*libc_getgrnam) (const char *) = (void *) 0; static struct passwd *(*libc_getpwnam) (const char *) = (void *) 0; static int (*libc_link) (const char *, const char *) = (void *) 0; @@ -46,12 +47,16 @@ static int (*libc_open) (const char *, int, mode_t) = (void *) 0; static int (*libc_open64) (const char *, int, mode_t) = (void *) 0; static int (*libc_symlink) (const char *, const char *) = (void *) 0; +static int (*libc___xstat) (int, const char *, struct stat *) = (void *) 0; +static int (*libc___xstat64) (int, const char *, struct stat64 *) = (void *) 0; uid_t euid; struct passwd root_pwd; struct group root_grp; const char *base_path; size_t base_path_len; +const char *package_path; +int package_fd; #define GET_NEXT_SYMBOL(name) \ do { \ @@ -62,12 +67,15 @@ static void __attribute__ ((constructor)) clickpreload_init (void) { + const char *package_fd_str; + /* Clear any old error conditions, albeit unlikely, as per dlsym(2) */ dlerror (); GET_NEXT_SYMBOL (chown); GET_NEXT_SYMBOL (execvp); GET_NEXT_SYMBOL (fchown); + GET_NEXT_SYMBOL (fopen); GET_NEXT_SYMBOL (getgrnam); GET_NEXT_SYMBOL (getpwnam); GET_NEXT_SYMBOL (link); @@ -77,6 +85,8 @@ GET_NEXT_SYMBOL (open); GET_NEXT_SYMBOL (open64); GET_NEXT_SYMBOL (symlink); + GET_NEXT_SYMBOL (__xstat); + GET_NEXT_SYMBOL (__xstat64); euid = geteuid (); /* dpkg only cares about these fields. */ @@ -85,6 +95,10 @@ base_path = getenv ("CLICK_BASE_DIR"); base_path_len = base_path ? strlen (base_path) : 0; + + package_path = getenv ("CLICK_PACKAGE_PATH"); + package_fd_str = getenv ("CLICK_PACKAGE_FD"); + package_fd = atoi (package_fd_str); } /* dpkg calls chown/fchown to set permissions of extracted files. If we @@ -228,12 +242,53 @@ return (*libc_mknod) (pathname, mode, dev); } +int symlink (const char *oldpath, const char *newpath) +{ + clickpreload_assert_path_in_instdir ("make symbolic link", newpath); + return (*libc_symlink) (oldpath, newpath); +} + +/* As well as write sandboxing, our versions of fopen, open, and stat also + * trap accesses to the package path and turn them into accesses to a fixed + * file descriptor instead. With some cooperation from click.install, this + * allows dpkg to read packages in paths not readable by the clickpkg user. + * + * We cannot do this entirely perfectly. In particular, we have to seek to + * the start of the file on open, but the file offset is shared among all + * duplicates of a file descriptor. Let's hope that dpkg doesn't open the + * .deb multiple times and expect to have independent file offsets ... + */ + +FILE *fopen (const char *pathname, const char *mode) +{ + int for_reading = + (strncmp (mode, "r", 1) == 0 && strncmp (mode, "r+", 2) != 0); + + if (for_reading && package_path && strcmp (pathname, package_path) == 0) { + int dup_fd = dup (package_fd); + lseek (dup_fd, 0, SEEK_SET); /* also changes offset of package_fd */ + return fdopen (dup_fd, mode); + } + + if (!for_reading) + clickpreload_assert_path_in_instdir ("write-fdopen", pathname); + + return (*libc_fopen) (pathname, mode); +} + int open (const char *pathname, int flags, ...) { + int for_writing = ((flags & O_WRONLY) || (flags & O_RDWR)); mode_t mode = 0; int ret; - if ((flags & O_WRONLY) || (flags & O_RDWR)) + if (!for_writing && package_path && strcmp (pathname, package_path) == 0) { + int dup_fd = dup (package_fd); + lseek (dup_fd, 0, SEEK_SET); /* also changes offset of package_fd */ + return dup_fd; + } + + if (for_writing) clickpreload_assert_path_in_instdir ("write-open", pathname); if (flags & O_CREAT) { @@ -249,10 +304,17 @@ int open64 (const char *pathname, int flags, ...) { + int for_writing = ((flags & O_WRONLY) || (flags & O_RDWR)); mode_t mode = 0; int ret; - if ((flags & O_WRONLY) || (flags & O_RDWR)) + if (!for_writing && package_path && strcmp (pathname, package_path) == 0) { + int dup_fd = dup (package_fd); + lseek (dup_fd, 0, SEEK_SET); /* also changes offset of package_fd */ + return dup_fd; + } + + if (for_writing) clickpreload_assert_path_in_instdir ("write-open", pathname); if (flags & O_CREAT) { @@ -266,8 +328,18 @@ return ret; } -int symlink (const char *oldpath, const char *newpath) +int __xstat (int ver, const char *pathname, struct stat *buf) { - clickpreload_assert_path_in_instdir ("make symbolic link", newpath); - return (*libc_symlink) (oldpath, newpath); + if (package_path && strcmp (pathname, package_path) == 0) + return __fxstat (ver, package_fd, buf); + + return (*libc___xstat) (ver, pathname, buf); +} + +int __xstat64 (int ver, const char *pathname, struct stat64 *buf) +{ + if (package_path && strcmp (pathname, package_path) == 0) + return __fxstat64 (ver, package_fd, buf); + + return (*libc___xstat64) (ver, pathname, buf); }