1# Copyright 2024 The Sigstore Authors
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15"""Machinery for serializing ML models."""
16
17import abc
18from collections.abc import Iterable
19import errno
20import logging
21import pathlib
22
23from model_signing import manifest
24
25
26logger = logging.getLogger(__name__)
27
28_WINDOWS_ERROR_CANT_RESOLVE_FILENAME = 1921
29
30
31def check_file_or_directory(
32 path: pathlib.Path, *, allow_symlinks: bool = False
33) -> None:
34 """Checks that the given path is either a file or a directory.
35
36 There is no support for sockets, pipes, or any other operating system
37 concept abstracted as a file.
38
39 Furthermore, this would raise if the path is a broken symlink, if it doesn't
40 exists or if there are permission errors.
41
42 Args:
43 path: The path to check.
44 allow_symlinks: Controls whether symbolic links are included. If a
45 symlink is present but the flag is `False` (default) the
46 serialization would raise an error.
47
48 Raises:
49 ValueError: The path is neither a file or a directory, or the path
50 is a symlink and `allow_symlinks` is false.
51 """
52 if not allow_symlinks and path.is_symlink():
53 raise ValueError(
54 f"Cannot use '{path}' because it is a symlink. This"
55 " behavior can be changed with `allow_symlinks`."
56 )
57 if not (path.is_file() or path.is_dir()):
58 raise ValueError(
59 f"Cannot use '{path}' as file or directory. It could be a"
60 " special file, it could be missing, or there might be a"
61 " permission issue."
62 )
63
64
65def check_symlink(path: pathlib.Path, model_path: pathlib.Path) -> bool:
66 """Warns about symlinks targeting outside the model root or forming cycles.
67
68 OMS v1.0 6.1.1: signer MUST report a warning when a symlink target is
69 outside the model root, or when a symlink cycle is detected.
70
71 Args:
72 path: The symlink path to check.
73 model_path: The root of the model directory.
74
75 Returns:
76 True if the symlink is usable, False if it should be skipped (cycle).
77
78 Raises:
79 ValueError: The symlink cannot be resolved (broken symlink).
80 """
81 try:
82 target = path.resolve(strict=True)
83 except RuntimeError:
84 # Python 3.12 and earlier raise RuntimeError on symlink loops.
85 logger.warning("Symlink cycle detected: '%s'", path)
86 return False
87 except OSError as err:
88 if err.errno == errno.ELOOP or (
89 getattr(err, "winerror", None)
90 == _WINDOWS_ERROR_CANT_RESOLVE_FILENAME
91 ):
92 logger.warning("Symlink cycle detected: '%s'", path)
93 return False
94 # A link that cannot be resolved is not a cycle. Skipping it would
95 # leave it out of the manifest at signing and leave it unreported at
96 # verification, so it must fail like any other unusable path.
97 raise ValueError(
98 f"Cannot use '{path}' because it is a broken symlink."
99 ) from err
100
101 model_root = model_path.resolve()
102 try:
103 target.relative_to(model_root)
104 except ValueError:
105 logger.warning(
106 "Symlink '%s' targets '%s' outside model root '%s'",
107 path,
108 target,
109 model_root,
110 )
111
112 return True
113
114
115def should_ignore(
116 path: pathlib.Path, ignore_paths: Iterable[pathlib.Path]
117) -> bool:
118 """Determines if the provided path should be ignored during serialization.
119
120 Args:
121 path: The path to check.
122 ignore_paths: The paths to ignore while serializing a model.
123
124 Returns:
125 Whether or not the provided path should be ignored.
126 """
127 return any(path.is_relative_to(ignore_path) for ignore_path in ignore_paths)
128
129
130class Serializer(metaclass=abc.ABCMeta):
131 """Generic ML model format serializer."""
132
133 @abc.abstractmethod
134 def serialize(
135 self,
136 model_path: pathlib.Path,
137 *,
138 ignore_paths: Iterable[pathlib.Path] = frozenset(),
139 ) -> manifest.Manifest:
140 """Serializes the model given by the `model_path` argument.
141
142 Args:
143 model_path: The path to the model.
144 ignore_paths: The paths to ignore during serialization. If a
145 provided path is a directory, all children of the directory are
146 ignored.
147
148 Returns:
149 The model's serialized manifest.
150 """