#
# Copyright (c) 2006-2021 Hal Brodigan (postmodern.mod3 at gmail.com)
#
# This file is part of ronin.
#
# Ronin 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, either version 3 of the License, or
# (at your option) any later version.
#
# Ronin 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 Ronin. If not, see .
#
require 'ronin/exceptions/duplicate_repository'
require 'ronin/exceptions/repository_not_found'
require 'ronin/model/has_name'
require 'ronin/model/has_title'
require 'ronin/model/has_description'
require 'ronin/model/has_license'
require 'ronin/model/has_authors'
require 'ronin/model'
require 'ronin/config'
require 'pullr'
require 'data_paths'
require 'yaml'
module Ronin
#
# Represents a container for user scripts and miscallaneous code,
# that can be distributed over common SCMs ([Git](http://git-scm.com/),
# [Mercurial (Hg)](http://mercurial.selenic.com/),
# [SubVersion (SVN)](http://subversion.tigris.org/), Rsync).
#
class Repository
include Model
include Model::HasName
include Model::HasTitle
include Model::HasDescription
include Model::HasAuthors
include Model::HasLicense
include DataPaths
# The default domain that repositories are added from
LOCAL_DOMAIN = 'localhost'
# Repository metadata file name
METADATA_FILE = 'ronin.yml'
# Repository `bin/` directory
BIN_DIR = 'bin'
# Repository `lib/` directory
LIB_DIR = 'lib'
# The `init.rb` file to load from the {LIB_DIR}
INIT_FILE = 'init.rb'
# Repository `data/` directory
DATA_DIR = 'data'
# Directories containing {Script}s
SCRIPT_DIRS = %w[scripts cache]
# The primary key of the repository
property :id, Serial
# The SCM used by the repository
property :scm, String
# Local path to the repository
property :path, FilePath, :required => true, :unique => true
# URI that the repository was installed from
property :uri, URI
# Specifies whether the repository was installed remotely
# or added using a local directory.
property :installed, Boolean, :default => false
# Name of the repository
property :name, String, :default => proc { |repo,name|
repo.path.basename
}
# The domain the repository belongs to
property :domain, String, :required => true
# Title of the repository
property :title, Text
# Source View URI of the repository
property :source, URI
# Website URI for the repository
property :website, URI
# Description of the repository
property :description, Text
# The script paths from the repository
has 0..n, :script_paths, :model => 'Script::Path'
# The `bin/` directory
attr_reader :bin_dir
# The `lib/` directory
attr_reader :lib_dir
# The `data/` directory
attr_reader :data_dir
# Directories containing {Script}s.
attr_reader :script_dirs
#
# Creates a new {Repository} object.
#
# @param [Hash] attributes
# The attributes of the repository.
#
# @option attributes [String] :path
# The path to the repository.
#
# @option attributes [Symbol] :scm
# The SCM used by the repository. Can be either:
#
# * `:git`
# * `:mercurial` / `:hg`
# * `:sub_version` / `:svn`
# * `:rsync`
#
# @option attributes [String, URI] :uri
# The URI the repository resides at.
#
# @yield [repo]
# If a block is given, the repository will be passed to it.
#
# @yieldparam [Repository] repo
# The newly created repository.
#
# @api private
#
def initialize(attributes={})
super(attributes)
@bin_dir = self.path.join(BIN_DIR)
@lib_dir = self.path.join(LIB_DIR)
@data_dir = self.path.join(DATA_DIR)
@script_dirs = SCRIPT_DIRS.map { |dir| self.path.join(dir) }
initialize_metadata
@activated = false
yield self if block_given?
end
#
# Searches for the Repository with a given name, and potentially
# installed from the given domain.
#
# @param [String] name
# The name of the repository.
#
# @return [Repository]
# The matching repository.
#
# @raise [RepositoryNotFound]
# No repository could be found with the given name or domain.
#
# @example Load the repository with the given name
# Repository.find('postmodern-repo')
#
# @example Load the repository with the given name and domain.
# Repository.find('postmodern-repo@github.com')
#
# @since 1.0.0
#
# @api private
#
def Repository.find(name)
name, domain = name.to_s.split('@',2)
query = {:name => name}
query[:domain] = domain if domain
unless (repo = Repository.first(query))
if domain
raise(RepositoryNotFound,"Repository #{name.dump} from domain #{domain.dump} cannot be found")
else
raise(RepositoryNotFound,"Repository #{name.dump} cannot be found")
end
end
return repo
end
#
# Adds an Repository with the given options.
#
# @return [Repository]
# The added repository.
#
# @raise [ArgumentError]
# The `:path` option was not specified.
#
# @raise [RepositoryNotFound]
# The path of the repository did not exist or was not a directory.
#
# @raise [DuplicateRepository]
# The repository was already added or installed.
#
# @since 1.0.0
#
# @api private
#
def Repository.add(options={})
unless options.has_key?(:path)
raise(ArgumentError,"the :path option was not given")
end
path = Pathname.new(options[:path]).expand_path
unless path.directory?
raise(RepositoryNotFound,"Repository #{path} cannot be found")
end
if Repository.count(:path => path) > 0
raise(DuplicateRepository,"a Repository at the path #{path} was already added")
end
# create the repository
repo = Repository.new(options.merge(
:path => path,
:installed => false,
:domain => LOCAL_DOMAIN
))
if Repository.count(:name => repo.name, :domain => repo.domain) > 0
raise(DuplicateRepository,"the Repository #{repo} already exists in the database")
end
# save the repository
if repo.save
# cache any files from within the `cache/` directory of the
# repository
repo.cache_scripts!
end
return repo
end
#
# Installs an repository.
#
# @param [Hash] options
# Additional options.
#
# @option options [Addressable::URI, String] :uri
# The URI to the repository.
#
# @option options [Symbol] :scm
# The SCM used by the repository. May be either:
#
# * `:git`
# * `:mercurial` / `:hg`
# * `:sub_version` / `:svn`
# * `:rsync`
#
# @return [Repository]
# The newly installed repository.
#
# @raise [ArgumentError]
# The `:uri` option must be specified.
#
# @raise [DuplicateRepository]
# An repository already exists with the same `name` and `host`
# properties.
#
# @since 1.0.0
#
# @api private
#
def Repository.install(options={})
unless options[:uri]
raise(ArgumentError,":uri must be passed to Repository.install")
end
remote_repo = Pullr::RemoteRepository.new(options)
name = remote_repo.name
domain = if remote_repo.uri.scheme
remote_repo.uri.host
else
# Use a regexp to pull out the host-name, if the URI
# lacks a scheme.
remote_repo.uri.to_s.match(/\@([^@:\/]+)/)[1]
end
if Repository.count(:name => name, :domain => domain) > 0
raise(DuplicateRepository,"a Repository already exists with the name #{name.dump} from domain #{domain.dump}")
end
path = File.join(Config::REPOS_DIR,name,domain)
# pull down the remote repository
local_repo = remote_repo.pull(path)
# add the new remote repository
repo = Repository.new(
:path => path,
:scm => local_repo.scm,
:uri => remote_repo.uri,
:installed => true,
:name => name,
:domain => domain
)
# save the repository
if repo.save
# cache any files from within the `cache/` directory of the
# repository
repo.cache_scripts!
end
return repo
end
#
# Updates all repositories.
#
# @yield [repo]
# If a block is given, it will be passed each updated repository.
#
# @yieldparam [Repository] repo
# An updated repository.
#
# @since 1.0.0
#
# @api private
#
def Repository.update!
Repository.each do |repo|
# update the repositories contents
repo.update!
yield repo if block_given?
end
end
#
# Uninstalls the repository with the given name or domain.
#
# @param [String] name
# The name of the repository to uninstall.
#
# @return [nil]
#
# @example Uninstall the repository with the given name
# Repository.uninstall('postmodern-repo')
#
# @example Uninstall the repository with the given name and domain.
# Repository.uninstall('postmodern-repo/github.com')
#
# @api private
#
def Repository.uninstall(name)
Repository.find(name).uninstall!
end
#
# Activates all installed or added repositories.
#
# @return [Array]
# The activated repository.
#
# @see #activate!
#
# @since 1.0.0
#
# @api private
#
def Repository.activate!
Repository.each { |repo| repo.activate! }
end
#
# Deactivates all installed or added repositories.
#
# @return [Array]
# The deactivated repositories.
#
# @see #deactivate!
#
# @since 1.0.0
#
# @api private
#
def Repository.deactivate!
Repository.reverse_each { |repo| repo.deactivate! }
end
#
# Determines if the repository was added locally.
#
# @return [Boolean]
# Specifies whether the repository was added locally.
#
# @since 1.0.0
#
# @api private
#
def local?
self.domain == LOCAL_DOMAIN
end
#
# Determines if the repository was installed remotely.
#
# @return [Boolean]
# Specifies whether the repository was installed from a remote URI.
#
# @since 1.0.0
#
# @api private
#
def remote?
self.domain != LOCAL_DOMAIN
end
#
# The executable scripts in the `bin/` directory.
#
# @return [Array]
# The executable script names.
#
# @since 1.0.0
#
# @api private
#
def executables
scripts = []
if @bin_dir.directory?
@bin_dir.entries.each do |path|
scripts << path.basename.to_s if path.file?
end
end
return scripts
end
#
# All paths within the `cache/` directory of the repository.
#
# @yield [path]
# If a block is given, it will be passed each matching path.
#
# @yieldparam [Pathname] path
# A matching path.
#
# @return [Enumerator]
# If no block is given, an Enumerator object will be returned.
#
# @since 1.0.0
#
# @api private
#
def each_script(&block)
return enum_for(__method__) unless block
@script_dirs.each do |dir|
Pathname.glob(dir.join('**','*.rb'),&block)
end
end
#
# Determines if the repository has been activated.
#
# @return [Boolean]
# Specifies whether the repository has been activated.
#
# @api private
#
def activated?
@activated == true
end
#
# Activates the repository by adding the {#lib_dir} to the `$LOAD_PATH`
# global variable.
#
# @api private
#
def activate!
# add the data/ directory
register_data_path(@data_dir) if @data_dir.directory?
if @lib_dir.directory?
# ensure all paths added to $LOAD_PATH are Strings
path = @lib_dir.to_s
$LOAD_PATH << path unless $LOAD_PATH.include?(path)
end
# load the lib/init.rb file
init_path = self.path.join(LIB_DIR,INIT_FILE)
require init_path if init_path.file?
@activated = true
return true
end
#
# Deactivates the repository by removing the {#lib_dir} from the
# `$LOAD_PATH` global variable.
#
# @api private
#
def deactivate!
unregister_data_paths
$LOAD_PATH.delete(@lib_dir.to_s)
@activated = false
return true
end
#
# Finds a cached script.
#
# @param [String] sub_path
# The sub-path within the repository to search for.
#
# @return [Script::Path, nil]
# The matching script path.
#
# @since 1.1.0
#
# @api private
#
def find_script(sub_path)
paths = @script_dirs.map { |dir| File.join(dir,sub_path) }
return script_paths.first(:path => paths)
end
#
# Clears the {#script_paths} and re-saves the cached files within the
# `cache/` directory.
#
# @return [Repository]
# The cleaned repository.
#
# @since 1.0.0
#
# @api private
#
def cache_scripts!
clean_scripts!
each_script do |path|
self.script_paths.new(:path => path).cache
end
return self
end
#
# Syncs the {#script_paths} of the repository, and adds any new cached
# files.
#
# @return [Repository]
# The cleaned repository.
#
# @since 1.0.0
#
# @api private
#
def sync_scripts!
# activates the repository before caching it's objects
activate!
new_paths = each_script.to_a
self.script_paths.each do |script_path|
# filter out pre-existing paths within the `cached/` directory
new_paths.delete(script_path.path)
# sync the cached file and catch any exceptions
script_path.sync
end
# cache the new paths within the `cache/` directory
new_paths.each do |path|
self.script_paths.new(:path => path).cache
end
# deactivates the repository
deactivate!
return self
end
#
# Deletes any {#script_paths} associated with the repository.
#
# @return [Repository]
# The cleaned repository.
#
# @since 1.0.0
#
# @api private
#
def clean_scripts!
self.script_paths.destroy
self.script_paths.clear
return self
end
#
# Updates the repository, reloads it's metadata and syncs the
# cached files of the repository.
#
# @yield [repo]
# If a block is given, it will be passed after the repository has
# been updated.
#
# @yieldparam [Repository] repo
# The updated repository.
#
# @return [Repository]
# The updated repository.
#
# @since 1.0.0
#
# @api private
#
def update!
local_repo = Pullr::LocalRepository.new(
:path => self.path,
:scm => self.scm
)
# only update if we have a repository
local_repo.update(self.uri)
# re-initialize the metadata
initialize_metadata
# save the repository
if save
# syncs the cached files of the repository
sync_scripts!
end
yield self if block_given?
return self
end
#
# Deletes the contents of the repository.
#
# @yield [repo]
# If a block is given, it will be passed the repository after it's
# contents have been deleted.
#
# @yieldparam [Repository] repo
# The deleted repository.
#
# @return [Repository]
# The deleted repository.
#
# @since 1.0.0
#
# @api private
#
def uninstall!
deactivate!
FileUtils.rm_rf(self.path) if self.installed?
# destroy any cached files first
clean_scripts!
# remove the repository from the database
destroy if saved?
yield self if block_given?
return self
end
#
# Converts the repository to a String.
#
# @return [String]
# The name and domain of the repository.
#
def to_s
"#{self.name}/#{self.domain}"
end
protected
#
# Loads the metadata from {METADATA_FILE} within the repository.
#
# @api private
#
def initialize_metadata
metadata_path = self.path.join(METADATA_FILE)
self.title = self.name
self.description = nil
self.license = nil
self.source = self.uri
self.website = self.source
self.authors.clear
if File.file?(metadata_path)
metadata = YAML.load_file(metadata_path)
if (title = metadata['title'])
self.title = title
end
if (description = metadata['description'])
self.description = description
end
if (license = metadata['license'])
self.license = License.first_or_predefined(:name => license)
end
if (uri = metadata['uri'])
self.uri ||= uri
end
if (source = metadata['source'])
self.source = source
end
if (website = metadata['website'])
self.website = website
end
case metadata['authors']
when Hash
metadata['authors'].each do |name,email|
self.authors << Author.first_or_new(:name => name, :email => email)
end
when Array
metadata['authors'].each do |name|
self.authors << Author.first_or_new(:name => name)
end
end
end
return self
end
end
end