# Copyright (C) VyOS Inc.
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library 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
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this library. If not, see .
#
# T6304: rework 'system config-management commit-archive' from a flat list of
# URLs ('location ') into a structured, per-destination tagNode carrying
# an explicit transport protocol, server, path, port and authentication.
import re
from urllib.parse import unquote
from urllib.parse import urlsplit
from vyos.configtree import ConfigTree
base = ['system', 'config-management', 'commit-archive']
# URL schemes that map 1:1 onto a transport protocol node of the same name;
# git schemes are handled separately below.
direct_schemes = {'scp', 'sftp', 'ssh', 'ftp', 'ftps', 'http', 'https', 'tftp'}
# transports the new 'git transport' leaf accepts ('file' is a local repo)
git_transports = {'https', 'http', 'ssh', 'file'}
def _decode_path(path: str) -> str:
"""Percent-decode a URL path, keeping an escaped slash (%2F) intact so it
is not turned into a path separator (the URL builder re-encodes it).
"""
return ''.join(
part if part.lower() == '%2f' else unquote(part)
for part in re.split('(%2[fF])', path)
)
def scheme_to_target(scheme: str):
"""Map a URL scheme to a (protocol node, git transport) tuple, or None if
unsupported. A git scheme maps to the 'git' node carrying its transport
('git' alone carries none); every other scheme maps to a node of its name.
"""
protocol, _, transport = scheme.partition('+')
if protocol == 'git':
# the old regex accepted any git+; the new schema only
# allows https/http/ssh/file, so reject others (e.g. git+rsync)
if transport and transport not in git_transports:
return None
return 'git', (transport or None)
if scheme in direct_schemes:
return scheme, None
return None
def migrate(config: ConfigTree) -> None:
location_base = base + ['location']
if not config.exists(location_base):
return
# already migrated: 'location' is a tagNode, not a leaf of URLs - a forced
# re-run must not delete and rebuild it
if config.is_tag(location_base):
return
migratable = []
for value in config.return_values(location_base):
url = urlsplit(value)
target = scheme_to_target(url.scheme)
if target is None:
# drop unsupported schemes with a warning; log only the scheme, not
# the URL, which may contain credentials
print(
f'Warning: commit-archive: dropping location with unsupported scheme "{url.scheme}"'
)
continue
proto, transport = target
local_git = proto == 'git' and transport == 'file'
if not local_git and not url.hostname:
# every remote protocol uploads to a host, so a URL with no server
# was never a usable target - drop it instead of writing an invalid
# location (git+file is local and legitimately has no host)
print(
f'Warning: commit-archive: dropping location with no server (scheme "{url.scheme}")'
)
continue
if local_git and not unquote(url.path):
# git+file has no server: the path IS the repository, and an empty
# one cannot be represented (an empty leaf cannot be set).
print(
'Warning: commit-archive: dropping git file location with no repository path'
)
continue
try:
# a non-numeric port (the old regex allowed it) raises here; keep
# the rest of the location and fall back to the protocol default
port = url.port
except ValueError:
print(
f'Warning: commit-archive: ignoring invalid port for location with scheme "{url.scheme}"'
)
port = None
migratable.append((url, target, port))
config.delete(location_base)
for index, (url, (proto, transport), port) in enumerate(migratable, start=1):
name = f'archive-{index}'
proto_base = location_base + [name, proto]
# git over file:// is a local repository: only a path and the 'file'
# transport, no server/port/credentials.
if proto == 'git' and transport == 'file':
config.set(proto_base + ['path'], value=unquote(url.path))
config.set(proto_base + ['transport'], value='file')
continue
config.set(proto_base + ['server'], value=url.hostname)
if port:
config.set(proto_base + ['port'], value=str(port))
# store the decoded path, the builder re-encodes it; keep an escaped
# slash (%2F) encoded so it is not turned into a path separator
path = _decode_path(url.path)
if path and path != '/':
config.set(proto_base + ['path'], value=path)
# urlsplit keeps the userinfo percent-encoded; store it decoded, as the
# URL builder re-encodes when assembling the upload URL
if proto != 'tftp':
if url.username:
config.set(
proto_base + ['authentication', 'username'],
value=unquote(url.username),
)
# ftp/ftps accept a password on its own (they fall back to the
# REMOTE_USERNAME/anonymous user); every other protocol needs a
# username, so a lone password is dropped to keep the config valid
allows_lone_password = proto in ('ftp', 'ftps')
if url.password and (url.username or allows_lone_password):
config.set(
proto_base + ['authentication', 'password'],
value=unquote(url.password),
)
# record the git transport whenever the URL specified one explicitly
# (git+https/git+http/git+ssh) - preserve the user's choice even if it
# matches the https default. A bare 'git' scheme carries none.
if proto == 'git' and transport:
config.set(proto_base + ['transport'], value=transport)
# mark 'location' as a tagNode
if migratable:
config.set_tag(location_base)